"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。""" from __future__ import annotations import logging import random import time from django.db import transaction from django.db.models import Q from django.utils import timezone from gongdan.constants import ( TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG, TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED, STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS, TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM, ) from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig logger = logging.getLogger(__name__) class GongdanError(Exception): def __init__(self, message, code='error'): super().__init__(message) self.code = code def _gen_gongdan_id(): ts = int(time.time() * 1000) ns = int(time.time_ns() // 1000) % 1000000 rnd = random.randint(0, 99) return f'GD{ts:011d}{ns:06d}{rnd:02d}' def _trim(text, max_len=MAX_SHUOMING_LEN): return (text or '').strip()[:max_len] def is_complaint_withdraw_block_enabled(club_id: str) -> bool: cid = (club_id or '').strip() if not cid: return False row = ClubGongdanConfig.query.filter(club_id=cid).first() return bool(row and row.complaint_withdraw_block) def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig: cid = (club_id or '').strip() row = ClubGongdanConfig.query.filter(club_id=cid).first() if not row: row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block)) row.save() return row def get_club_gongdan_config(club_id: str) -> dict: cid = (club_id or '').strip() if not cid: return { 'club_id': '', 'complaint_withdraw_block': False, 'human_kefu_wechat': '', 'human_kefu_link': '', 'human_kefu_tip': '', 'human_kefu_available': False, } row = ClubGongdanConfig.query.filter(club_id=cid).first() wechat = ((row.human_kefu_wechat if row else '') or '').strip() link = ((row.human_kefu_link if row else '') or '').strip() tip = ((row.human_kefu_tip if row else '') or '').strip() return { 'club_id': cid, 'complaint_withdraw_block': bool(row and row.complaint_withdraw_block), 'human_kefu_wechat': wechat, 'human_kefu_link': link, 'human_kefu_tip': tip or ('添加人工客服微信咨询' if wechat else ('点击联系人工客服' if link else '')), 'human_kefu_available': bool(wechat or link), } def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict: cid = (club_id or '').strip() if not cid: raise GongdanError('俱乐部不能为空', 'need_club') row = ensure_club_gongdan_config(cid, withdraw_block=bool(enabled)) enabled = bool(enabled) if row.complaint_withdraw_block != enabled: row.complaint_withdraw_block = enabled row.save(update_fields=['complaint_withdraw_block', 'UpdateTime']) return get_club_gongdan_config(cid) def update_club_gongdan_config(club_id: str, **fields) -> dict: """更新俱乐部工单配置(提现拦截 / 人工客服联系方式)。""" cid = (club_id or '').strip() if not cid: raise GongdanError('俱乐部不能为空', 'need_club') row = ensure_club_gongdan_config(cid) dirty = [] if 'complaint_withdraw_block' in fields and fields['complaint_withdraw_block'] is not None: val = bool(fields['complaint_withdraw_block']) if row.complaint_withdraw_block != val: row.complaint_withdraw_block = val dirty.append('complaint_withdraw_block') if 'human_kefu_wechat' in fields and fields['human_kefu_wechat'] is not None: val = str(fields['human_kefu_wechat'] or '').strip()[:64] if (row.human_kefu_wechat or '') != val: row.human_kefu_wechat = val dirty.append('human_kefu_wechat') if 'human_kefu_link' in fields and fields['human_kefu_link'] is not None: val = str(fields['human_kefu_link'] or '').strip()[:512] if (row.human_kefu_link or '') != val: row.human_kefu_link = val dirty.append('human_kefu_link') if 'human_kefu_tip' in fields and fields['human_kefu_tip'] is not None: val = str(fields['human_kefu_tip'] or '').strip()[:120] if (row.human_kefu_tip or '') != val: row.human_kefu_tip = val dirty.append('human_kefu_tip') if dirty: dirty.append('UpdateTime') row.save(update_fields=dirty) return get_club_gongdan_config(cid) def resolve_inviter_complaint_target(yonghuid: str, leixing: int) -> dict: """ 管事/组长投诉只能针对本人邀请人,禁止搜索乱选: - 管事投诉:打手的 yaoqingren,且对方须是管事 - 组长投诉:管事的 yaoqingren,且对方须是组长 无邀请人则不可投诉。 """ uid = (yonghuid or '').strip() if not uid: raise GongdanError('请先登录', 'need_login') from users.models import UserDashou, UserGuanshi, UserZuzhang if leixing == TYPE_GUANSHI: ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first() if not ds: raise GongdanError('仅打手可投诉自己的管事', 'need_dashou') inviter = (ds.yaoqingren or '').strip() if not inviter: raise GongdanError('你没有邀请人,无法投诉管事', 'no_inviter') gs = UserGuanshi.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first() if not gs: raise GongdanError('你的邀请人不是有效管事,无法投诉', 'inviter_not_guanshi') return { 'role': TARGET_ROLE_GUANSHI, 'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI], 'user_id': inviter, 'nicheng': _user_nicheng(inviter), 'can_complain': True, } if leixing == TYPE_ZUZHANG: gs = UserGuanshi.query.filter(user__UserUID=uid).select_related('user').first() if not gs: raise GongdanError('仅管事可投诉自己的组长', 'need_guanshi') inviter = (gs.yaoqingren or '').strip() if not inviter: raise GongdanError('你没有邀请人,无法投诉组长', 'no_inviter') zz = UserZuzhang.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first() if not zz: raise GongdanError('你的邀请人不是有效组长,无法投诉', 'inviter_not_zuzhang') return { 'role': TARGET_ROLE_ZUZHANG, 'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG], 'user_id': inviter, 'nicheng': _user_nicheng(inviter), 'can_complain': True, } raise GongdanError('仅管事/组长投诉需要邀请人', 'bad_type') def peek_inviter_complaint_target(yonghuid: str, leixing: int) -> dict: """前端展示用:失败时返回 can_complain=False + 原因,不抛错。""" try: return resolve_inviter_complaint_target(yonghuid, leixing) except GongdanError as e: role = TARGET_ROLE_GUANSHI if leixing == TYPE_GUANSHI else TARGET_ROLE_ZUZHANG return { 'role': role, 'role_label': TARGET_ROLE_LABELS.get(role, ''), 'user_id': '', 'nicheng': '', 'can_complain': False, 'reason': str(e), } def _user_nicheng(uid: str) -> str: """展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。""" uid = (uid or '').strip() if not uid: return '' try: from users.models import UserDashou, UserBoss from users.business_models import User ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first() if ds and (ds.nicheng or '').strip(): return (ds.nicheng or '').strip()[:32] boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first() if boss and (boss.nickname or '').strip(): return (boss.nickname or '').strip()[:32] u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first() if u and (getattr(u, 'Phone', None) or '').strip(): phone = str(u.Phone).strip() if len(phone) >= 4: return f'用户*{phone[-4:]}' return '用户' except Exception: return '用户' def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None, *, yonghuid=None): """归一化被投诉对象。管事/组长投诉强制本人邀请人,忽略前端传入的选人。""" role = (target_role or '').strip().lower() tid = (target_user_id or '').strip() gid = (guanshi_id or '').strip() or None if leixing == TYPE_GUANSHI: info = resolve_inviter_complaint_target(yonghuid, TYPE_GUANSHI) tid = info['user_id'] return TARGET_ROLE_GUANSHI, tid, tid if leixing == TYPE_ZUZHANG: info = resolve_inviter_complaint_target(yonghuid, TYPE_ZUZHANG) tid = info['user_id'] return TARGET_ROLE_ZUZHANG, tid, None if role and tid: if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'): raise GongdanError('无效的被投诉角色', 'bad_target_role') return role, tid, gid if role == TARGET_ROLE_GUANSHI else None return role or '', tid or '', gid def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None): order_id = (order_id or '').strip() or None chongzhi_id = (chongzhi_id or '').strip() or None fadan_id = (fadan_id or '').strip() or None guanshi_id = (guanshi_id or '').strip() or None if leixing == TYPE_ORDER: if not order_id: raise GongdanError('订单投诉请填写订单ID', 'need_order_id') from orders.models import Order if not Order.query.filter(OrderID=order_id).exists(): raise GongdanError('订单不存在,请核对订单ID', 'order_not_found') return order_id, None, None, None if leixing == TYPE_RECHARGE: if not chongzhi_id: raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id') from products.models import Czjilu from products.czjilu_types import CZJILU_PAID_STATUS cz = Czjilu.query.filter(dingdan_id=chongzhi_id).first() if not cz: raise GongdanError('充值记录不存在,请核对充值ID', 'chongzhi_not_found') try: z = int(cz.zhuangtai) if cz.zhuangtai is not None else None except (TypeError, ValueError): z = None if z != CZJILU_PAID_STATUS: raise GongdanError('仅已支付的充值单可投诉', 'chongzhi_not_paid') return None, chongzhi_id, None, None if leixing == TYPE_PENALTY: if not fadan_id: raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id') from orders.models import Penalty try: fid = int(fadan_id) except (TypeError, ValueError): raise GongdanError('罚单ID格式不正确', 'bad_fadan_id') if not Penalty.query.filter(id=fid).exists(): raise GongdanError('罚单不存在,请核对罚单ID', 'fadan_not_found') return None, None, str(fid), None if leixing == TYPE_GUANSHI: return None, None, None, guanshi_id if leixing in (TYPE_FRAUD, TYPE_ZUZHANG): return None, None, None, None raise GongdanError('不支持的投诉类型', 'bad_type') def _effective_target_uid(gd) -> str: return (gd.target_user_id or gd.guanshi_id or '').strip() def _effective_target_role(gd) -> str: role = (gd.target_role or '').strip() if role: return role if gd.leixing == TYPE_GUANSHI or gd.guanshi_id: return TARGET_ROLE_GUANSHI if gd.leixing == TYPE_ZUZHANG: return TARGET_ROLE_ZUZHANG return '' def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False): """ 序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。 for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。 contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。 """ t_role = _effective_target_role(gd) t_uid = _effective_target_uid(gd) relation = '' if viewer_uid: if gd.shenqingren_id == viewer_uid: relation = 'created' elif t_uid and t_uid == viewer_uid: relation = 'received' else: relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else '' data = { 'gongdan_id': gd.gongdan_id, 'leixing': gd.leixing, 'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)), 'order_id': gd.order_id or '', 'chongzhi_id': gd.chongzhi_id or '', 'fadan_id': gd.fadan_id or '', 'shuoming': gd.shuoming or '', 'club_id': gd.club_id or '', 'zhuangtai': gd.zhuangtai, 'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)), 'chuli_jieguo': gd.chuli_jieguo or '', # 对外:只显示「平台已介入」,不返回客服 UID 'platform_intervened': bool(gd.platform_intervened), 'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '', 'intervene_reason': gd.intervene_reason or '', 'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '', 'shengji_cishu': gd.shengji_cishu or 0, 'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '', 'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '', 'target_role': t_role, 'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''), 'target_nicheng': _user_nicheng(t_uid), 'complainant_nicheng': _user_nicheng(gd.shenqingren_id), 'relation': relation, 'is_open': gd.zhuangtai in OPEN_STATUSES, 'can_contact': False, 'contact_peer_uid': '', 'contact_peer_nicheng': '', 'can_contact_complainant': False, 'contact_complainant_uid': '', 'contact_complainant_nicheng': '', } if viewer_uid and relation in ('created', 'received'): peer = t_uid if relation == 'created' else gd.shenqingren_id if peer and peer != viewer_uid: data['can_contact'] = True data['contact_peer_uid'] = peer data['contact_peer_nicheng'] = _user_nicheng(peer) elif viewer_uid and relation == 'linked': # 牵连组长:默认可联系主被投诉管事;另可联系投诉人 if t_uid and t_uid != viewer_uid: data['can_contact'] = True data['contact_peer_uid'] = t_uid data['contact_peer_nicheng'] = _user_nicheng(t_uid) if gd.shenqingren_id and gd.shenqingren_id != viewer_uid: data['can_contact_complainant'] = True data['contact_complainant_uid'] = gd.shenqingren_id data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id) if for_admin: # 客服需要 UID 做内部处理;用户端绝不下发 data['shenqingren_id'] = gd.shenqingren_id or '' data['target_user_id'] = t_uid or '' data['guanshi_id'] = gd.guanshi_id or '' data['chuliren_id'] = gd.chuliren_id or '' data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '') else: data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else '' if include_images: data['images'] = [ {'url': img.url, 'paixu': img.paixu} for img in gd.images.all().order_by('paixu', 'id') ] if include_shengji: data['shengji_list'] = [ { 'cishu': s.cishu, 'shuoming': s.shuoming, 'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '', } for s in gd.shengji_list.all().order_by('cishu', 'id') ] return data def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool: """下级管事被投诉时,其上级组长视为牵连方。""" t_uid = _effective_target_uid(gd) t_role = _effective_target_role(gd) if not t_uid or t_role != TARGET_ROLE_GUANSHI: return False try: from users.models import UserGuanshi gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first() if not gs: return False return (gs.yaoqingren or '').strip() == str(viewer_uid) except Exception: return False def _sub_guanshi_uids(zuzhang_uid: str): from users.models import UserGuanshi rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user') out = [] for r in rows: uid = getattr(getattr(r, 'user', None), 'UserUID', None) if uid: out.append(str(uid)) return out def open_complaint_q_for_target_uids(uids): """未结清且指向这些 UID 的工单条件。""" uids = [str(u) for u in (uids or []) if u] if not uids: return Q(pk__in=[]) return ( Q(zhuangtai__in=OPEN_STATUSES) & (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids)) ) def check_complaint_blocks_withdraw(user_main, club_id=None): """ 提现拦截。 返回 (ok, msg, extra) extra: reason=received|linked, tab, count """ try: from jituan.services.club_user import get_user_club_id cid = (club_id or get_user_club_id(user_main) or '').strip() except Exception: cid = (club_id or '').strip() if not is_complaint_withdraw_block_enabled(cid): return True, '', {} uid = str(user_main.UserUID) base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all() # 1) 自己是主被投诉人 qs_recv = base.filter(open_complaint_q_for_target_uids([uid])) cnt = qs_recv.count() if cnt: return False, '你有未处理完的投诉,请处理后再提现', { 'reason': 'received', 'tab': 'received', 'count': cnt, 'code': 'COMPLAINT_BLOCK_WITHDRAW', } # 2) 自己是组长,下级管事被投诉 → 牵连 try: from users.models import UserZuzhang is_zz = UserZuzhang.query.filter(user=user_main).exists() except Exception: is_zz = False if is_zz: subs = _sub_guanshi_uids(uid) if subs: qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter( Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI) ) cnt2 = qs_link.count() if cnt2: return False, '下级管事有未处理投诉,暂不可提现', { 'reason': 'linked', 'tab': 'linked', 'count': cnt2, 'code': 'COMPLAINT_BLOCK_WITHDRAW', } return True, '', {} def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20, yonghuid=None): """已废弃搜索选人:仅返回本人邀请人(防乱投诉)。keyword/club 忽略。""" role = (role or '').strip().lower() if role == TARGET_ROLE_GUANSHI: leixing = TYPE_GUANSHI elif role == TARGET_ROLE_ZUZHANG: leixing = TYPE_ZUZHANG else: raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role') info = peek_inviter_complaint_target(yonghuid, leixing) rows = [] if info.get('can_complain') and info.get('user_id'): rows.append({ 'user_id': info['user_id'], 'nicheng': info.get('nicheng') or '', 'role': info.get('role') or role, 'role_label': info.get('role_label') or '', }) return { 'list': rows, 'total': len(rows), 'page': 1, 'page_size': 1, 'role': role, 'can_complain': bool(info.get('can_complain')), 'reason': info.get('reason') or '', 'invite_only': True, } def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20): """ scope: created — 我发起的 received — 我被投诉的 linked — 下级牵连(组长) """ scope = (scope or 'created').strip().lower() page = max(int(page or 1), 1) page_size = min(max(int(page_size or 20), 1), 50) uid = str(yonghuid) qs = Gongdan.query.all() if club_id: qs = qs.filter(club_id=club_id) if scope == 'received': qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime') elif scope == 'linked': subs = _sub_guanshi_uids(uid) if not subs: qs = qs.none() else: qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter( Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI) ).order_by('-CreateTime') else: qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime') total = qs.count() start = (page - 1) * page_size rows = list(qs[start:start + page_size]) return { 'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows], 'total': total, 'page': page, 'page_size': page_size, 'scope': scope, } def user_can_view_gongdan(gd, yonghuid) -> bool: uid = str(yonghuid) if gd.shenqingren_id == uid: return True if _effective_target_uid(gd) == uid: return True if _viewer_is_linked_zuzhang(uid, gd): return True return False def _replace_images(gd, image_urls): urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()] if len(urls) > MAX_IMAGES: raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images') GongdanImage.objects.filter(gongdan=gd).delete() for i, url in enumerate(urls): GongdanImage.objects.create( gongdan=gd, gongdan_hao=gd.gongdan_id, url=url[:512], paixu=i, ) @transaction.atomic def create_gongdan( *, yonghuid, leixing, shuoming, club_id=None, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None, target_role=None, target_user_id=None, image_urls=None, ): try: leixing = int(leixing) except (TypeError, ValueError): raise GongdanError('投诉类型无效', 'bad_type') if leixing not in TYPE_LABELS: raise GongdanError('投诉类型无效', 'bad_type') text = _trim(shuoming) if not text: raise GongdanError('请填写投诉说明', 'need_shuoming') order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs( leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id, ) t_role, t_uid, guanshi_id = _resolve_target( leixing, guanshi_id=guanshi_id, target_role=target_role, target_user_id=target_user_id or guanshi_id, yonghuid=yonghuid, ) gid = _gen_gongdan_id() for _ in range(5): if not Gongdan.query.filter(gongdan_id=gid).exists(): break gid = _gen_gongdan_id() gd = Gongdan.objects.create( gongdan_id=gid, leixing=leixing, order_id=order_id, chongzhi_id=chongzhi_id, fadan_id=fadan_id, guanshi_id=guanshi_id, target_role=t_role or '', target_user_id=t_uid or '', shuoming=text, shenqingren_id=yonghuid, club_id=club_id or None, zhuangtai=STATUS_PROCESSING, shengji_cishu=0, ) _replace_images(gd, image_urls) logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid) return gd @transaction.atomic def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None): gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') if gd.shenqingren_id != yonghuid: raise GongdanError('无权操作此工单', 'forbidden') if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED): raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status') if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES: raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji') text = _trim(shuoming) if not text: raise GongdanError('请填写补充说明', 'need_shuoming') next_cishu = (gd.shengji_cishu or 0) + 1 GongdanShengji.objects.create( gongdan=gd, gongdan_hao=gd.gongdan_id, cishu=next_cishu, shuoming=text, shenqingren_id=yonghuid, ) gd.shengji_cishu = next_cishu gd.zhuangtai = STATUS_PROCESSING gd.chuli_jieguo = '' gd.chuli_time = None gd.save(update_fields=[ 'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime', ]) if image_urls is not None: _replace_images(gd, image_urls) return gd @transaction.atomic def handle_gongdan( *, gongdan_id, handler_uid, handler_name, action, chuli_jieguo='', intervene_reason='', ): """ action: done/finish/resolve → 已处理 close → 关闭 processing/reopen → 处理中 dissatisfied → 不满意 intervene → 标记平台介入(不改结案状态) negotiate → 协商中(客服 sparingly) """ gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') action = (action or '').strip().lower() jieguo = _trim(chuli_jieguo) if action in ('intervene', 'platform_intervene'): gd.platform_intervened = True gd.intervene_at = timezone.now() gd.intervene_by = (handler_uid or '')[:32] gd.intervene_reason = (intervene_reason or 'kefu')[:32] if gd.zhuangtai not in OPEN_STATUSES: gd.zhuangtai = STATUS_PROCESSING gd.save() logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid) return gd if action in ('done', 'finish', 'resolve'): if not jieguo: raise GongdanError('请填写处理结果', 'need_jieguo') gd.zhuangtai = STATUS_DONE gd.chuli_jieguo = jieguo gd.chuliren_id = handler_uid gd.chuliren_name = (handler_name or '平台客服')[:64] gd.chuli_time = timezone.now() if not gd.platform_intervened: gd.platform_intervened = True gd.intervene_at = gd.intervene_at or timezone.now() gd.intervene_by = (handler_uid or '')[:32] gd.intervene_reason = gd.intervene_reason or 'kefu_done' elif action in ('close',): gd.zhuangtai = STATUS_CLOSED gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭' gd.chuliren_id = handler_uid gd.chuliren_name = (handler_name or '平台客服')[:64] gd.chuli_time = timezone.now() elif action in ('processing', 'reopen'): gd.zhuangtai = STATUS_PROCESSING elif action in ('negotiate',): gd.zhuangtai = STATUS_NEGOTIATING elif action in ('dissatisfied',): gd.zhuangtai = STATUS_DISSATISFIED else: raise GongdanError('未知处理动作', 'bad_action') gd.save() logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid) return gd @transaction.atomic def user_mark_dissatisfied(*, gongdan_id, yonghuid): gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') if gd.shenqingren_id != yonghuid: raise GongdanError('无权操作此工单', 'forbidden') if gd.zhuangtai != STATUS_DONE: raise GongdanError('仅已处理的工单可标记不满意', 'bad_status') gd.zhuangtai = STATUS_DISSATISFIED gd.save(update_fields=['zhuangtai', 'UpdateTime']) return gd @transaction.atomic def user_resolve_gongdan(*, gongdan_id, yonghuid): """投诉人确认已解决(私了结清)。""" gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') if gd.shenqingren_id != yonghuid: raise GongdanError('仅投诉人可确认解决', 'forbidden') if gd.zhuangtai not in OPEN_STATUSES: raise GongdanError('当前状态无需确认解决', 'bad_status') gd.zhuangtai = STATUS_DONE gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决' gd.chuli_time = timezone.now() gd.chuliren_name = gd.chuliren_name or '双方协商' gd.save() return gd @transaction.atomic def user_start_negotiate(*, gongdan_id, yonghuid): gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') if not user_can_view_gongdan(gd, yonghuid): raise GongdanError('无权操作此工单', 'forbidden') if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING): raise GongdanError('当前状态不可转协商', 'bad_status') gd.zhuangtai = STATUS_NEGOTIATING gd.save(update_fields=['zhuangtai', 'UpdateTime']) return gd @transaction.atomic def user_apply_intervene(*, gongdan_id, yonghuid): """用户申请平台介入:打标,仍保持未结清。""" gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') if gd.shenqingren_id != yonghuid and _effective_target_uid(gd) != yonghuid: raise GongdanError('无权申请介入', 'forbidden') if gd.zhuangtai not in OPEN_STATUSES: raise GongdanError('当前状态不可申请介入', 'bad_status') gd.platform_intervened = True if not gd.intervene_at: gd.intervene_at = timezone.now() gd.intervene_reason = gd.intervene_reason or 'user_apply' if gd.zhuangtai == STATUS_NEGOTIATING: gd.zhuangtai = STATUS_PROCESSING gd.save() return gd