fix: current_role是MariaDB保留字致CREATE INDEX/WHERE全1064——改名assignee_role/assignee_id

This commit is contained in:
yumoqing 2026-09-10 16:24:39 +08:00
parent 54f2f2d597
commit fa415aa379
7 changed files with 47 additions and 47 deletions

View File

@ -17,8 +17,8 @@
{"name": "status", "title": "工单状态", "type": "str", "length": 32, "nullable": "no", "default": "new"},
{"name": "customer_org_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "customer_user_id", "title": "客户用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "current_role", "title": "当前受理角色", "type": "str", "length": 64},
{"name": "current_assignee", "title": "当前受理人", "type": "str", "length": 32},
{"name": "assignee_role", "title": "当前受理角色", "type": "str", "length": 64},
{"name": "assignee_id", "title": "当前受理人", "type": "str", "length": 32},
{"name": "processing_owner", "title": "agent处理锁标记", "type": "str", "length": 64},
{"name": "followup_count", "title": "客户追问次数", "type": "int", "default": "0"},
{"name": "agent_fail_rounds", "title": "agent连续失败轮数", "type": "int", "default": "0"},
@ -31,8 +31,8 @@
{"name": "uk_tk_no", "idxtype": "unique", "idxfields": ["ticket_no"]},
{"name": "idx_tk_cust", "idxtype": "index", "idxfields": ["customer_org_id", "status"]},
{"name": "idx_tk_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_tk_role_status", "idxtype": "index", "idxfields": ["current_role", "status"]},
{"name": "idx_tk_assignee", "idxtype": "index", "idxfields": ["current_assignee", "status"]}
{"name": "idx_tk_role_status", "idxtype": "index", "idxfields": ["assignee_role", "status"]},
{"name": "idx_tk_assignee", "idxtype": "index", "idxfields": ["assignee_id", "status"]}
],
"codes": [
{"field": "category", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_category'"},

View File

@ -9,7 +9,7 @@
staff 动作端点claim/staff_reply/transfer/transfer_candidates/staff_tickets
只能授 logined 而不能授固定角色D4 决策转派目标是 owner 组织类型下的
**任意动态角色**role 表运行时可增删RBAC 路径权限无法枚举动态角色
业务权限由服务端 _check_staff_access 校验操作者须持有工单 current_role
业务权限由服务端 _check_staff_access 校验操作者须持有工单 assignee_role
admin/superuser 豁免客户侧数据由 I5 服务端自过滤customer_user_id/org_id
- owner.maintainer / owner.admin / owner.superuser管理页壳 /ticket/manage/**
菜单工单管理入口也按角色 Jinja 分支渲染客户直接访问 URL 403
@ -42,7 +42,7 @@ PATHS_LOGINED = [
f"/{MOD}/api/ticket_followup.dspy",
f"/{MOD}/api/ticket_confirm.dspy",
f"/{MOD}/api/ticket_cancel.dspy",
# staff 侧 API服务端 _check_staff_access 按工单 current_role 动态校验)
# staff 侧 API服务端 _check_staff_access 按工单 assignee_role 动态校验)
f"/{MOD}/api/staff_tickets.dspy",
f"/{MOD}/api/ticket_claim.dspy",
f"/{MOD}/api/ticket_staff_reply.dspy",

View File

@ -226,8 +226,8 @@ async def process_one_ticket(ticket_id):
# 基础设施故障T4fail_rounds+1 回退 new超限转人工
if fail_rounds + 1 >= fail_max:
moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, {
'status': core.S_HUMAN_PENDING, 'current_role': default_role,
'current_assignee': None, 'processing_owner': None,
'status': core.S_HUMAN_PENDING, 'assignee_role': default_role,
'assignee_id': None, 'processing_owner': None,
'agent_fail_rounds': fail_rounds + 1})
if moved:
await core._add_message(
@ -263,8 +263,8 @@ async def process_one_ticket(ticket_id):
# T3答不了 → 转人工
moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, {
'status': core.S_HUMAN_PENDING, 'current_role': default_role,
'current_assignee': None, 'processing_owner': None, 'agent_fail_rounds': 0})
'status': core.S_HUMAN_PENDING, 'assignee_role': default_role,
'assignee_id': None, 'processing_owner': None, 'agent_fail_rounds': 0})
if moved:
await core._add_message(
sor, ticket_id, 'agent', core.AGENT_USER, '',

View File

@ -2,10 +2,10 @@
"""ticket/core.py — 工单状态机与全部迁移动作。
不变量设计文档 §4.3
- I1 处理方唯一status+current_role+current_assignee 联合表达迁移全部乐观锁
- I1 处理方唯一status+assignee_role+assignee_id 联合表达迁移全部乐观锁
UPDATE ... WHERE status=前置态 + SELECT 验证 pipeline_service claim 范式
- I2 human_pending current_role 非空 AND current_assignee NULL
- I3 human_processing/staff_replied current_assignee 非空
- I2 human_pending assignee_role 非空 AND assignee_id NULL
- I3 human_processing/staff_replied assignee_id 非空
- I4 受理方每次变更必写 tk_transfers同事务
- I5 客户只见自己工单 + visibility='customer' 消息
- I6 终态closed/cancelled不接受任何迁移
@ -215,8 +215,8 @@ async def create_ticket(user_id, org_id, title, description, category='other',
'status': S_NEW,
'customer_org_id': str(org_id or ''),
'customer_user_id': str(user_id),
'current_role': None,
'current_assignee': None,
'assignee_role': None,
'assignee_id': None,
'processing_owner': None,
'followup_count': 0,
'agent_fail_rounds': 0,
@ -281,8 +281,8 @@ async def ticket_followup(ticket_id, user_id, org_id, content):
if cur > followup_max:
default_role = await _get_param(sor, 'ticket_default_role', 'owner.maintainer')
ok = await _cas_status(sor, ticket_id, S_AGENT_REPLIED, {
'status': S_HUMAN_PENDING, 'current_role': default_role,
'current_assignee': None, 'followup_count': cur})
'status': S_HUMAN_PENDING, 'assignee_role': default_role,
'assignee_id': None, 'followup_count': cur})
if ok:
await _add_message(sor, ticket_id, 'agent', AGENT_USER, '',
'客户追问超过 %d 次,自动转人工处理。' % followup_max,
@ -317,7 +317,7 @@ async def ticket_confirm(ticket_id, user_id, org_id):
return False, ('TK_E002', '工单当前状态为 %s,不能确认关闭(需已有回复)' % status)
ok = await _cas_status(sor, ticket_id, status, {
'status': S_CLOSED, 'closed_at': _now(), 'close_reason': 'resolved',
'current_role': None, 'current_assignee': None})
'assignee_role': None, 'assignee_id': None})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_message(sor, ticket_id, 'customer', user_id, '',
@ -341,7 +341,7 @@ async def ticket_cancel(ticket_id, user_id, org_id):
return False, ('TK_E002', '智能助手正在处理中,请稍后再取消')
ok = await _cas_status(sor, ticket_id, status, {
'status': S_CANCELLED, 'closed_at': _now(), 'close_reason': 'cancelled',
'current_role': None, 'current_assignee': None})
'assignee_role': None, 'assignee_id': None})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_message(sor, ticket_id, 'customer', user_id, '', '客户取消工单。')
@ -351,10 +351,10 @@ async def ticket_cancel(ticket_id, user_id, org_id):
# ══════════════════ 运维动作 ══════════════════
async def _check_staff_access(sor, t, user_id, roles):
"""运维侧访问admin/superuser 豁免;否则须持有工单 current_role。"""
"""运维侧访问admin/superuser 豁免;否则须持有工单 assignee_role。"""
if _is_admin(roles):
return True
cur_role = str(getattr(t, 'current_role', '') or '')
cur_role = str(getattr(t, 'assignee_role', '') or '')
return bool(cur_role) and cur_role in roles
@ -368,9 +368,9 @@ async def ticket_claim(ticket_id, user_id):
return False, E_NOT_FOUND
if not await _check_staff_access(sor, t, user_id, roles):
return False, ('TK_E001', '您不是该工单的受理角色,无法认领')
cur_role = str(getattr(t, 'current_role', '') or '')
cur_role = str(getattr(t, 'assignee_role', '') or '')
ok = await _cas_status(sor, ticket_id, S_HUMAN_PENDING, {
'status': S_HUMAN_PROCESSING, 'current_assignee': user_id})
'status': S_HUMAN_PROCESSING, 'assignee_id': user_id})
if not ok:
return False, ('TK_E005', '该工单刚被他人认领,请刷新队列')
await _add_transfer(sor, ticket_id, 'claim', from_role=cur_role,
@ -391,7 +391,7 @@ async def ticket_staff_reply(ticket_id, user_id, content, internal=False):
t = await _load_ticket(sor, ticket_id)
if not t:
return False, E_NOT_FOUND
cur_assignee = str(getattr(t, 'current_assignee', '') or '')
cur_assignee = str(getattr(t, 'assignee_id', '') or '')
if not (_is_admin(roles) or cur_assignee == str(user_id)):
return False, ('TK_E001', '只有当前受理人可以回复该工单')
status = str(getattr(t, 'status', ''))
@ -440,8 +440,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='')
status = str(getattr(t, 'status', ''))
if status != S_HUMAN_PROCESSING:
return False, ('TK_E002', '工单当前状态为 %s,不能转派(需认领后 human_processing' % status)
cur_role = str(getattr(t, 'current_role', '') or '')
cur_assignee = str(getattr(t, 'current_assignee', '') or '')
cur_role = str(getattr(t, 'assignee_role', '') or '')
cur_assignee = str(getattr(t, 'assignee_id', '') or '')
if to_user:
# T12 转具体人:校验目标 ∈ '0' 机构用户
@ -456,8 +456,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='')
owner_roles = [r for r in troles if r.startswith('owner.')]
new_role = owner_roles[0] if owner_roles else cur_role
ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, {
'status': S_HUMAN_PROCESSING, 'current_role': new_role,
'current_assignee': to_user})
'status': S_HUMAN_PROCESSING, 'assignee_role': new_role,
'assignee_id': to_user})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_transfer(sor, ticket_id, 'transfer_user',
@ -476,8 +476,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='')
return False, ('TK_E003', '目标角色不在 owner 组织类型角色列表中,请重新选择')
full_role = to_role if '.' in to_role else 'owner.' + to_role
ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, {
'status': S_HUMAN_PENDING, 'current_role': full_role,
'current_assignee': None})
'status': S_HUMAN_PENDING, 'assignee_role': full_role,
'assignee_id': None})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_transfer(sor, ticket_id, 'transfer_role',
@ -527,7 +527,7 @@ async def list_customer_tickets(user_id, org_id, roles=None, status='', limit=10
params["st"] = status
wsql = (' WHERE ' + ' AND '.join(where)) if where else ''
recs = await sor.sqlExe(
"SELECT id, ticket_no, title, category, priority, status, current_role, "
"SELECT id, ticket_no, title, category, priority, status, assignee_role, "
"followup_count, closed_at, close_reason, created_at, updated_at "
"FROM tk_tickets" + wsql + " ORDER BY created_at DESC LIMIT ${lim}$", params)
await sor.sqlExe("COMMIT", {})
@ -548,10 +548,10 @@ async def list_staff_tickets(user_id, roles=None, queue='all', limit=100):
ph = ','.join("'role%d'" % i for i in range(len(roles)))
for i, r in enumerate(roles):
params['role%d' % i] = r
cond = ("status='human_pending' AND current_role IN (%s)" % ph)
cond = ("status='human_pending' AND assignee_role IN (%s)" % ph)
elif queue == 'mine':
cond = ("status IN ('human_processing','staff_replied') "
"AND current_assignee=${u}$")
"AND assignee_id=${u}$")
params["u"] = str(user_id or '')
else:
if not _is_admin(roles) and not any(r.startswith('owner.') for r in roles):
@ -559,11 +559,11 @@ async def list_staff_tickets(user_id, roles=None, queue='all', limit=100):
cond = "status NOT IN ('closed','cancelled') OR closed_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
recs = await sor.sqlExe(
"SELECT t.id, t.ticket_no, t.title, t.category, t.priority, t.status, "
"t.current_role, t.current_assignee, t.customer_org_id, t.created_at, "
"t.assignee_role, t.assignee_id, t.customer_org_id, t.created_at, "
"t.updated_at, o.orgname AS customer_org_name, u.username AS assignee_name "
"FROM tk_tickets t "
"LEFT JOIN organization o ON t.customer_org_id=o.id "
"LEFT JOIN users u ON t.current_assignee=u.id "
"LEFT JOIN users u ON t.assignee_id=u.id "
"WHERE " + cond + " ORDER BY t.updated_at DESC LIMIT ${lim}$", params)
await sor.sqlExe("COMMIT", {})
return [_rec_to_dict(r) for r in (recs or [])]

View File

@ -3,8 +3,8 @@
待办由工单状态派生不建通知表I7 同一事项只发一次
- 客户status {agent_replied, staff_replied} 且工单是我的 工单回复待确认
- 运维角色池status=human_pending current_role 我的角色 工单认领
- 受理人status=human_processing current_assignee= 工单处理
- 运维角色池status=human_pending assignee_role 我的角色 工单认领
- 受理人status=human_processing assignee_id= 工单处理
provider 签名fn(user_id, roles, limit) -> [todo dict]
todo dict 字段与 list_my_human_todos 现有两来源对齐 + source='ticket' + detail_url
@ -37,15 +37,15 @@ async def list_ticket_todos(user_id, roles=None, limit=100):
# ① 客户视角:我的工单已有回复待确认
conds.append("(customer_user_id=${u}$ AND status IN ('agent_replied','staff_replied'))")
# ② 受理人视角:我认领的工单待处理
conds.append("(current_assignee=${u}$ AND status='human_processing')")
conds.append("(assignee_id=${u}$ AND status='human_processing')")
# ③ 角色池视角:待认领且受理角色∈我的角色
if roles:
ph = ','.join("'r%d'" % i for i in range(len(roles)))
for i, r in enumerate(roles):
params['r%d' % i] = r
conds.append("(status='human_pending' AND current_role IN (%s))" % ph)
sql = ("SELECT id, ticket_no, title, status, current_role, customer_user_id, "
"current_assignee, updated_at, created_at FROM tk_tickets "
conds.append("(status='human_pending' AND assignee_role IN (%s))" % ph)
sql = ("SELECT id, ticket_no, title, status, assignee_role, customer_user_id, "
"assignee_id, updated_at, created_at FROM tk_tickets "
"WHERE (" + ' OR '.join(conds) + ") "
"ORDER BY updated_at DESC LIMIT ${lim}$")
recs = await sor.sqlExe(sql, params)
@ -60,13 +60,13 @@ async def list_ticket_todos(user_id, roles=None, limit=100):
badge = '工单回复'
summary = '%s 请确认是否解决' % STATUS_LABEL.get(status, status)
ttype = 'ticket_customer_confirm'
elif str(d.get('current_assignee', '')) == uid and status == 'human_processing':
elif str(d.get('assignee_id', '')) == uid and status == 'human_processing':
badge = '工单处理'
summary = '您受理的工单待处理'
ttype = 'ticket_staff_process'
elif status == 'human_pending':
badge = '工单认领'
summary = '受理角色:%s 待认领' % str(d.get('current_role', ''))
summary = '受理角色:%s 待认领' % str(d.get('assignee_role', ''))
ttype = 'ticket_staff_claim'
else:
continue

View File

@ -44,8 +44,8 @@ md_parts.append('**状态**' + STATUS_LABEL.get(status, status)
+ ' **优先级**' + str(t.get('priority', '')))
if view == 'staff':
md_parts.append('**客户机构**' + str(t.get('customer_org_id', ''))
+ ' **受理角色**' + (str(t.get('current_role', '')) or '-')
+ ' **受理人**' + (str(t.get('current_assignee', '')) or '-'))
+ ' **受理角色**' + (str(t.get('assignee_role', '')) or '-')
+ ' **受理人**' + (str(t.get('assignee_id', '')) or '-'))
md_parts.append('')
md_parts.append('---')
md_parts.append('### 往来记录')

View File

@ -38,7 +38,7 @@
},
"row_options": {
"browserfields": {
"exclouded": ["id", "customer_org_id", "current_assignee"],
"exclouded": ["id", "customer_org_id", "assignee_id"],
"alters": {
"category": {"uitype": "code", "data": [
{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"},
@ -60,7 +60,7 @@
{"name": "priority", "title": "优先级", "type": "str", "length": 16, "cwidth": 6},
{"name": "status", "title": "状态", "type": "str", "length": 32, "cwidth": 10},
{"name": "customer_org_name", "title": "客户机构", "type": "str", "length": 100, "cwidth": 14},
{"name": "current_role", "title": "受理角色", "type": "str", "length": 64, "cwidth": 12},
{"name": "assignee_role", "title": "受理角色", "type": "str", "length": 64, "cwidth": 12},
{"name": "assignee_name", "title": "受理人", "type": "str", "length": 32, "cwidth": 8},
{"name": "updated_at", "title": "最后更新", "type": "timestamp", "cwidth": 14}
]