Files
Django/gongdan/services.py

823 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。"""
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