新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。 Co-authored-by: Cursor <cursoragent@cursor.com>
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""角色协议:后台配置 + 小程序勾选签署(按俱乐部隔离)。"""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from django.utils import timezone
|
||
|
||
from jituan.constants import CLUB_ID_DEFAULT
|
||
from jituan.models import ClubRoleAgreement, ClubRoleAgreementSign
|
||
from jituan.services.club_context import resolve_club_id_from_request
|
||
|
||
ROLES = (
|
||
('dashou', '打手/接单员'),
|
||
('guanshi', '管事'),
|
||
('zuzhang', '组长'),
|
||
)
|
||
|
||
ROLE_SET = {r[0] for r in ROLES}
|
||
|
||
# 卡点:可后台逐项开关;默认贴近产品约定
|
||
GATE_DEFS = (
|
||
('qiangdan', '抢单/接单时'),
|
||
('register', '身份注册时'),
|
||
('tixian', '提现时'),
|
||
('invite', '邀请下级时'),
|
||
)
|
||
GATE_SET = {g[0] for g in GATE_DEFS}
|
||
|
||
DEFAULT_GATES = {
|
||
'dashou': {'qiangdan': True, 'register': False, 'tixian': False, 'invite': False},
|
||
'guanshi': {'qiangdan': False, 'register': True, 'tixian': True, 'invite': False},
|
||
'zuzhang': {'qiangdan': False, 'register': False, 'tixian': True, 'invite': False},
|
||
}
|
||
|
||
|
||
def _club(request) -> str:
|
||
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
|
||
|
||
|
||
def _images(val) -> List[str]:
|
||
if not val:
|
||
return []
|
||
if isinstance(val, list):
|
||
return [str(x).strip() for x in val if str(x).strip()][:20]
|
||
return []
|
||
|
||
|
||
def _clamp_read_seconds(val) -> int:
|
||
try:
|
||
n = int(val)
|
||
except (TypeError, ValueError):
|
||
n = 2
|
||
if n < 0:
|
||
n = 0
|
||
if n > 300:
|
||
n = 300
|
||
return n
|
||
|
||
|
||
def normalize_gates(role: str, raw) -> Dict[str, bool]:
|
||
base = dict(DEFAULT_GATES.get(role) or {k: False for k in GATE_SET})
|
||
if isinstance(raw, dict):
|
||
for k in GATE_SET:
|
||
if k in raw:
|
||
base[k] = bool(raw.get(k))
|
||
return base
|
||
|
||
|
||
def _row_to_dict(row: Optional[ClubRoleAgreement], club_id: str, role: str) -> Dict[str, Any]:
|
||
if not row:
|
||
return {
|
||
'club_id': club_id,
|
||
'role': role,
|
||
'enabled': False,
|
||
'title': '',
|
||
'content_text': '',
|
||
'images': [],
|
||
'read_seconds': 2,
|
||
'gate_config': normalize_gates(role, None),
|
||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||
'version': 0,
|
||
'published_at': '',
|
||
'exists': False,
|
||
}
|
||
return {
|
||
'club_id': row.club_id,
|
||
'role': row.role,
|
||
'enabled': bool(row.enabled),
|
||
'title': row.title or '',
|
||
'content_text': row.content_text or '',
|
||
'images': _images(row.images_json),
|
||
'read_seconds': _clamp_read_seconds(getattr(row, 'read_seconds', 2)),
|
||
'gate_config': normalize_gates(role, getattr(row, 'gate_config', None)),
|
||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||
'version': int(row.version or 0),
|
||
'published_at': row.published_at.isoformat() if row.published_at else '',
|
||
'updated_by': row.updated_by or '',
|
||
'exists': True,
|
||
'id': row.id,
|
||
}
|
||
|
||
|
||
def build_admin_payload(request) -> Dict[str, Any]:
|
||
club_id = _club(request)
|
||
rows = {
|
||
r.role: r
|
||
for r in ClubRoleAgreement.query.filter(club_id=club_id)
|
||
}
|
||
items = []
|
||
for role, name in ROLES:
|
||
d = _row_to_dict(rows.get(role), club_id, role)
|
||
d['role_name'] = name
|
||
items.append(d)
|
||
return {
|
||
'club_id': club_id,
|
||
'roles': items,
|
||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||
}
|
||
|
||
|
||
def save_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||
club_id = _club(request)
|
||
role = (data.get('role') or '').strip().lower()
|
||
if role not in ROLE_SET:
|
||
return None, '无效角色'
|
||
|
||
title = (data.get('title') or '')[:128]
|
||
content_text = data.get('content_text') or ''
|
||
if len(content_text) > 50000:
|
||
return None, '正文过长'
|
||
images = _images(data.get('images') or data.get('images_json'))
|
||
enabled = bool(data.get('enabled'))
|
||
read_seconds = _clamp_read_seconds(data.get('read_seconds', 2))
|
||
gate_config = normalize_gates(role, data.get('gate_config'))
|
||
|
||
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
|
||
if not row:
|
||
row = ClubRoleAgreement(club_id=club_id, role=role, version=0)
|
||
row.enabled = enabled
|
||
row.title = title
|
||
row.content_text = content_text
|
||
row.images_json = images
|
||
row.read_seconds = read_seconds
|
||
row.gate_config = gate_config
|
||
row.updated_by = (operator or '')[:32]
|
||
row.save()
|
||
out = _row_to_dict(row, club_id, role)
|
||
out['role_name'] = dict(ROLES).get(role, role)
|
||
return out, None
|
||
|
||
|
||
def publish_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||
"""保存内容并 version+1,强制用户重签新版本。"""
|
||
club_id = _club(request)
|
||
role = (data.get('role') or '').strip().lower()
|
||
if role not in ROLE_SET:
|
||
return None, '无效角色'
|
||
|
||
saved, err = save_agreement(request, data, operator=operator)
|
||
if err:
|
||
return None, err
|
||
|
||
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
|
||
if not row:
|
||
return None, '协议不存在'
|
||
if not (row.title or '').strip() and not (row.content_text or '').strip() and not _images(row.images_json):
|
||
return None, '请先填写标题、正文或上传图片再发布'
|
||
|
||
row.version = int(row.version or 0) + 1
|
||
row.published_at = timezone.now()
|
||
row.enabled = True if data.get('enabled') is None else bool(data.get('enabled'))
|
||
row.updated_by = (operator or '')[:32]
|
||
row.save()
|
||
out = _row_to_dict(row, club_id, role)
|
||
out['role_name'] = dict(ROLES).get(role, role)
|
||
return out, None
|
||
|
||
|
||
def get_current_for_user(
|
||
club_id: str,
|
||
user_id: str,
|
||
role: str,
|
||
*,
|
||
gate: str = '',
|
||
view_only: bool = False,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
取某角色当前协议。
|
||
- gate 非空:仅当该卡点开启且未签当前版时 need_sign=True
|
||
- view_only:只读查看,不强制签署
|
||
"""
|
||
role = (role or '').strip().lower()
|
||
if role not in ROLE_SET:
|
||
return {'need_sign': False, 'enabled': False, 'msg': '无效角色'}
|
||
|
||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
|
||
empty = {
|
||
'need_sign': False,
|
||
'enabled': False,
|
||
'club_id': cid,
|
||
'role': role,
|
||
'version': 0,
|
||
'title': '',
|
||
'content_text': '',
|
||
'images': [],
|
||
'read_seconds': 0,
|
||
'gate_config': normalize_gates(role, None),
|
||
'gate_active': False,
|
||
'already_signed': False,
|
||
}
|
||
if not row or int(row.version or 0) <= 0:
|
||
return empty
|
||
|
||
version = int(row.version)
|
||
gates = normalize_gates(role, getattr(row, 'gate_config', None))
|
||
signed = ClubRoleAgreementSign.query.filter(
|
||
club_id=cid, user_id=str(user_id), role=role, version=version,
|
||
).exists()
|
||
enabled = bool(row.enabled)
|
||
read_seconds = _clamp_read_seconds(getattr(row, 'read_seconds', 2))
|
||
|
||
gate_key = (gate or '').strip().lower()
|
||
gate_active = True
|
||
if gate_key:
|
||
if gate_key not in GATE_SET:
|
||
return {**empty, 'msg': '无效卡点', 'enabled': enabled}
|
||
gate_active = bool(gates.get(gate_key))
|
||
|
||
need_sign = False
|
||
if enabled and not view_only and not signed and gate_active:
|
||
need_sign = True
|
||
# 无 gate 且非 view:兼容旧调用,只要启用且未签就 need_sign
|
||
if enabled and not view_only and not signed and not gate_key:
|
||
need_sign = True
|
||
|
||
return {
|
||
'need_sign': need_sign,
|
||
'enabled': enabled,
|
||
'club_id': cid,
|
||
'role': role,
|
||
'role_name': dict(ROLES).get(role, role),
|
||
'version': version,
|
||
'title': row.title or '角色服务协议',
|
||
'content_text': row.content_text or '',
|
||
'images': _images(row.images_json),
|
||
'read_seconds': read_seconds,
|
||
'gate_config': gates,
|
||
'gate': gate_key,
|
||
'gate_active': gate_active if gate_key else enabled,
|
||
'agreement_id': row.id,
|
||
'published_at': row.published_at.isoformat() if row.published_at else '',
|
||
'already_signed': signed,
|
||
}
|
||
|
||
|
||
def list_for_user(club_id: str, user_id: str, roles: Optional[List[str]] = None) -> Dict[str, Any]:
|
||
"""小程序「我的协议」列表:按俱乐部返回已发布协议摘要。"""
|
||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||
want = [r for r in (roles or list(ROLE_SET)) if r in ROLE_SET]
|
||
if not want:
|
||
want = list(ROLE_SET)
|
||
items = []
|
||
for role in want:
|
||
d = get_current_for_user(cid, user_id, role, view_only=True)
|
||
if int(d.get('version') or 0) <= 0:
|
||
continue
|
||
items.append({
|
||
'role': d['role'],
|
||
'role_name': d.get('role_name') or role,
|
||
'title': d.get('title') or '',
|
||
'version': d.get('version') or 0,
|
||
'already_signed': bool(d.get('already_signed')),
|
||
'enabled': bool(d.get('enabled')),
|
||
'published_at': d.get('published_at') or '',
|
||
})
|
||
return {'club_id': cid, 'items': items}
|
||
|
||
|
||
def sign_agreement(
|
||
club_id: str,
|
||
user_id: str,
|
||
role: str,
|
||
*,
|
||
client_meta: Optional[dict] = None,
|
||
agreed: bool = False,
|
||
) -> Tuple[Optional[dict], Optional[str]]:
|
||
"""勾选确认签署;不再要求手写签名图。"""
|
||
role = (role or '').strip().lower()
|
||
if role not in ROLE_SET:
|
||
return None, '无效角色'
|
||
if not agreed:
|
||
return None, '请勾选同意协议'
|
||
|
||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
|
||
if not row or not row.enabled or int(row.version or 0) <= 0:
|
||
return None, '当前无需签署或协议未发布'
|
||
|
||
version = int(row.version)
|
||
existed = ClubRoleAgreementSign.query.filter(
|
||
club_id=cid, user_id=str(user_id), role=role, version=version,
|
||
).first()
|
||
if existed:
|
||
return {
|
||
'already_signed': True,
|
||
'sign_id': existed.id,
|
||
'version': version,
|
||
'signed_at': existed.signed_at.isoformat() if existed.signed_at else '',
|
||
}, None
|
||
|
||
meta = dict(client_meta or {})
|
||
meta.setdefault('sign_mode', 'checkbox')
|
||
sign = ClubRoleAgreementSign.query.create(
|
||
club_id=cid,
|
||
user_id=str(user_id),
|
||
role=role,
|
||
agreement_id=row.id,
|
||
version=version,
|
||
title_snapshot=(row.title or '')[:128],
|
||
content_snapshot=row.content_text or '',
|
||
images_snapshot=_images(row.images_json),
|
||
sign_image_url='',
|
||
client_meta=meta,
|
||
)
|
||
return {
|
||
'already_signed': False,
|
||
'sign_id': sign.id,
|
||
'version': version,
|
||
'signed_at': sign.signed_at.isoformat() if sign.signed_at else '',
|
||
}, None
|
||
|
||
|
||
def upload_agreement_image(file_obj, club_id: str, kind: str = 'content') -> Tuple[Optional[str], Optional[str]]:
|
||
"""后台上传协议配图,返回相对路径。"""
|
||
from utils.oss_utils import validate_image, upload_to_oss
|
||
|
||
ok, err = validate_image(file_obj)
|
||
if not ok:
|
||
return None, err or '图片无效'
|
||
cid = (club_id or 'xq').strip() or 'xq'
|
||
kind = 'content'
|
||
ext = 'png'
|
||
name = getattr(file_obj, 'name', '') or ''
|
||
if '.' in name:
|
||
ext = name.rsplit('.', 1)[-1].lower()[:8] or 'png'
|
||
key = f'role_agreement/{cid}/{kind}/{uuid.uuid4().hex}.{ext}'
|
||
try:
|
||
path = upload_to_oss(file_obj, key)
|
||
if not path:
|
||
return None, '上传失败'
|
||
except Exception as e:
|
||
return None, f'上传失败: {e}'
|
||
return path, None
|