feat: 工单管理模块初版——客户提问→agent预处理→人工运维→转派,接入平台待办

- 3表: tk_tickets(状态机+受理角色/人+agent锁)/tk_messages(客户可见/内部备注)/tk_transfers(append-only流水)
- core.py: 8态13迁移状态机全乐观锁; 转派候选动态查owner角色+'0'机构用户(不硬编码);
  默认受理角色params可配(ticket_default_role=owner.maintainer)
- agent.py: 异步poller(bid_flow范式), RAG检索'0'机构KB+历史工单, llm_call(org_id=0)
  LLM裁决能否回答(严格JSON), 硬门禁reply<20字符转人工, 故障≠答不了(fail_rounds>=3转人工),
  stale回收10min; 追问回流(agent_processing+owner空)立即拾取不等stale
- todos.py: 平台待办provider, 状态派生(客户待确认/角色池待认领/受理人待处理)
- init.py: load_ticket注册env+provider+poller(PIPELINE_MODE=web不启动)
- wwwroot: 客户我的工单页+建单表单, 运维工单管理页(队列切换), 详情弹窗(正文与操作同屏,按视角出按钮)
- load_path.py: logined全部API(业务权限服务端按current_role动态校验)+管理页壳owner三角色
- init/data.json: 7组码表+owner.maintainer角色种子(app_audit先例)
This commit is contained in:
yumoqing 2026-09-10 16:14:19 +08:00
parent f1aec291e9
commit 54f2f2d597
28 changed files with 2101 additions and 1 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.egg-info/
build/
dist/

View File

@ -1,2 +1,47 @@
# ticket
# ticket — 平台工单管理模块
客户提出问题 → 登记工单 → agent 先处理(检索'0'机构知识库+历史工单,能答直接回复客户)
→ 答不了转人工运维owner.maintainerparams 可配)→ 人工回复客户 → 关闭;
人工处理不了可转派 owner 组织类型下任意角色或具体用户。
## 关键设计(详见 pipeline-app/docs 或设计文档 ticket-module-design.md
- **状态机**8 态 13 迁移全部乐观锁UPDATE...WHERE status=前置态 + SELECT 验证,
照 pipeline_service agent_loop 的 claim 范式),并发冲突返回明确错误码。
- **平台待办集成**(用户定夺 2026-09-10工单不建独立通知表通过
`register_todo_provider` 钩子接入平台待办通道pipeline-service 聚合,
todo_badge 角标 + my_todos 弹窗异步通知到人)。待办由工单状态派生:
客户回复待确认 / 运维角色池待认领 / 受理人待处理——状态流转待办自动消失,
天然满足「同一事项只发一次」。
- **角色不硬编码**:默认受理角色存 paramsticket_default_role兜底 owner.maintainer
转派候选实时查 role 表 owner 组织类型全部角色 + '0' 机构用户role/orgtypes 运行时可动态增删)。
- **agent**:异步 poller照 bid_flow 范式PIPELINE_MODE=web 不启动),
RAG 检索走 rag HTTP APIdapi Bearer key平台账号='0'机构 → 只见 org_id='0' 的 KB
LLM 走 pipeline_service.llm_bridge.llm_call内部 token+门禁链+记账org_id='0')。
「能否回答」由 LLM 裁决(语义判断铁律,禁关键词匹配),严格 JSON 输出;
硬门禁reply 空/短于20字符一律转人工。基础设施故障LLM/RAG异常≠答不了
fail_rounds+1 回退重试≥3 轮转人工reason=agent服务不可用
## 数据表3张tk_ 前缀)
| 表 | 说明 |
|---|------|
| tk_tickets | 工单主表(状态机+当前受理角色/人+agent处理锁 |
| tk_messages | 往来消息visibility: customer客户可见/internal内部备注 |
| tk_transfers | 转派流水append-only每次受理方变更必留痕 |
## 参数params 表,运行时可改)
| 参数 | 默认 | 说明 |
|------|------|------|
| ticket_default_role | owner.maintainer | agent 转人工的默认受理角色 |
| ticket_followup_max | 2 | agent 回复后客户追问上限,超限自动转人工 |
| ticket_agent_fail_max | 3 | agent 基础设施连续失败轮数上限,超限转人工 |
| ticket_agent_username | admin | agent 检索知识库用的平台账号('0'机构) |
## 安装
宿主 build.sh 四处清单 + `load_ticket()`(见宿主集成)。模块注册:
- env.create_ticket / ticket_detail / ticket_followup / ... dspy 消费)
- register_todo_provider(list_ticket_todos)(平台待办聚合)
- start_poller()agent 后台处理)

83
init/data.json Normal file
View File

@ -0,0 +1,83 @@
{
"_note": "ticket 模块种子码表嵌套格式import_init.py normalize 展开)+ owner.maintainer 角色(照 app_audit 的 owner.audit 先例。params 参数ticket_default_role 等不在种子——core.py _get_param 有兜底默认值,运行时在参数管理页可调。",
"appcodes": [
{
"parentid": "tk_status",
"parentname": "工单状态",
"items": [
{"k": "new", "v": "已提交"},
{"k": "agent_processing", "v": "助手处理中"},
{"k": "agent_replied", "v": "助手已回复"},
{"k": "human_pending", "v": "待人工认领"},
{"k": "human_processing", "v": "人工处理中"},
{"k": "staff_replied", "v": "人工已回复"},
{"k": "closed", "v": "已关闭"},
{"k": "cancelled", "v": "已取消"}
]
},
{
"parentid": "tk_category",
"parentname": "工单分类",
"items": [
{"k": "consult", "v": "使用咨询"},
{"k": "fault", "v": "故障报修"},
{"k": "billing", "v": "计费账务"},
{"k": "other", "v": "其他"}
]
},
{
"parentid": "tk_priority",
"parentname": "工单优先级",
"items": [
{"k": "low", "v": "低"},
{"k": "normal", "v": "普通"},
{"k": "high", "v": "高"},
{"k": "urgent", "v": "紧急"}
]
},
{
"parentid": "tk_sender_type",
"parentname": "工单消息发送方",
"items": [
{"k": "customer", "v": "客户"},
{"k": "agent", "v": "智能助手"},
{"k": "staff", "v": "人工客服"}
]
},
{
"parentid": "tk_visibility",
"parentname": "工单消息可见性",
"items": [
{"k": "customer", "v": "客户可见"},
{"k": "internal", "v": "内部备注"}
]
},
{
"parentid": "tk_transfer_action",
"parentname": "工单流转动作",
"items": [
{"k": "escalate_to_human", "v": "转人工"},
{"k": "claim", "v": "认领"},
{"k": "transfer_role", "v": "转角色"},
{"k": "transfer_user", "v": "转人员"},
{"k": "return", "v": "退回"}
]
},
{
"parentid": "tk_close_reason",
"parentname": "工单关闭原因",
"items": [
{"k": "resolved", "v": "已解决"},
{"k": "cancelled", "v": "客户取消"},
{"k": "timeout", "v": "超时关闭"}
]
}
],
"roles": [
{
"id": "owner.maintainer",
"orgtypeid": "owner",
"name": "maintainer"
}
]
}

29
models/tk_messages.json Normal file
View File

@ -0,0 +1,29 @@
{
"summary": [
{
"name": "tk_messages",
"title": "工单往来消息",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "ticket_id", "title": "工单ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "sender_type", "title": "发送方类型", "type": "str", "length": 16, "nullable": "no"},
{"name": "sender_id", "title": "发送人ID", "type": "str", "length": 32},
{"name": "sender_role", "title": "发送时角色", "type": "str", "length": 64},
{"name": "content", "title": "消息正文", "type": "longtext"},
{"name": "attachments", "title": "附件JSON", "type": "text"},
{"name": "visibility", "title": "可见性", "type": "str", "length": 16, "nullable": "no", "default": "customer"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_tkm_ticket", "idxtype": "index", "idxfields": ["ticket_id", "created_at"]},
{"name": "idx_tkm_vis", "idxtype": "index", "idxfields": ["visibility"]}
],
"codes": [
{"field": "sender_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_sender_type'"},
{"field": "visibility", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_visibility'"}
]
}

43
models/tk_tickets.json Normal file
View File

@ -0,0 +1,43 @@
{
"summary": [
{
"name": "tk_tickets",
"title": "工单主表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "ticket_no", "title": "工单编号", "type": "str", "length": 32, "nullable": "no"},
{"name": "title", "title": "问题标题", "type": "str", "length": 200, "nullable": "no"},
{"name": "description", "title": "问题描述", "type": "text"},
{"name": "category", "title": "问题分类", "type": "str", "length": 32, "default": "other"},
{"name": "priority", "title": "优先级", "type": "str", "length": 16, "default": "normal"},
{"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": "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"},
{"name": "closed_at", "title": "关闭时间", "type": "timestamp"},
{"name": "close_reason", "title": "关闭原因", "type": "str", "length": 32},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"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"]}
],
"codes": [
{"field": "category", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_category'"},
{"field": "priority", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_priority'"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_status'"},
{"field": "close_reason", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_close_reason'"}
]
}

28
models/tk_transfers.json Normal file
View File

@ -0,0 +1,28 @@
{
"summary": [
{
"name": "tk_transfers",
"title": "工单转派流水",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "ticket_id", "title": "工单ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "action", "title": "动作", "type": "str", "length": 32, "nullable": "no"},
{"name": "from_role", "title": "转出角色", "type": "str", "length": 64},
{"name": "from_user", "title": "转出人", "type": "str", "length": 32},
{"name": "to_role", "title": "转入角色", "type": "str", "length": 64},
{"name": "to_user", "title": "转入人", "type": "str", "length": 32},
{"name": "reason", "title": "原因", "type": "str", "length": 500},
{"name": "operator_id", "title": "操作人", "type": "str", "length": 32},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_tkt_ticket", "idxtype": "index", "idxfields": ["ticket_id", "created_at"]}
],
"codes": [
{"field": "action", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_transfer_action'"}
]
}

17
pyproject.toml Normal file
View File

@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ticket"
version = "0.1.0"
description = "平台工单管理客户提问→agent预处理→人工运维→转派接入平台待办通道"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"sqlor",
"bricks_for_python"
]
[tool.setuptools]
packages = ["ticket"]

88
scripts/load_path.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""RBAC path registration for ticket module (pipeline format).
从应用根执行py3/bin/python pkgs/ticket/scripts/load_path.py
load_path.sh 自动扫描 pkgs/*/scripts/load_path.py 并调用本脚本
权限设计设计文档 §7.2逐个精确配不用统一兜底
- logined模块页面壳 + 全部 API
staff 动作端点claim/staff_reply/transfer/transfer_candidates/staff_tickets
只能授 logined 而不能授固定角色D4 决策转派目标是 owner 组织类型下的
**任意动态角色**role 表运行时可增删RBAC 路径权限无法枚举动态角色
业务权限由服务端 _check_staff_access 校验操作者须持有工单 current_role
admin/superuser 豁免客户侧数据由 I5 服务端自过滤customer_user_id/org_id
- owner.maintainer / owner.admin / owner.superuser管理页壳 /ticket/manage/**
菜单工单管理入口也按角色 Jinja 分支渲染客户直接访问 URL 403
"""
import sys
import os
import asyncio
# 应用根:本脚本在 pkgs/ticket/scripts/ 下,上溯三级
APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../.."))
sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages"))
sys.path.insert(0, APP_ROOT)
from sqlor.dbpools import DBPools
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
MOD = "ticket"
PATHS_LOGINED = [
# 客户侧页面
f"/{MOD}/my",
f"/{MOD}/my/index.ui",
f"/{MOD}/my/new_ticket_form.ui",
# 客户侧 APII5 服务端自过滤)
f"/{MOD}/api/ticket_create.dspy",
f"/{MOD}/api/my_tickets.dspy",
f"/{MOD}/api/ticket_detail.dspy",
f"/{MOD}/api/ticket_detail_popup.dspy",
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 动态校验)
f"/{MOD}/api/staff_tickets.dspy",
f"/{MOD}/api/ticket_claim.dspy",
f"/{MOD}/api/ticket_staff_reply.dspy",
f"/{MOD}/api/ticket_transfer.dspy",
f"/{MOD}/api/transfer_candidates.dspy",
]
# 管理页壳:运维三角色
PATHS_STAFF = [
f"/{MOD}/manage",
f"/{MOD}/manage/index.ui",
]
STAFF_ROLES = ["owner.maintainer", "owner.admin", "owner.superuser"]
async def main():
config = getConfig(APP_ROOT, NS={"workdir": APP_ROOT})
DBPools(config.databases)
cnt = 0
async with DBPools().sqlorContext("pipeline") as sor:
pairs = [("logined", p) for p in PATHS_LOGINED]
for role in STAFF_ROLES:
pairs += [(role, p) for p in PATHS_STAFF]
for role, path in pairs:
recs = await sor.R("permission", {"path": path})
if recs:
permid = recs[0].id
else:
permid = getID()
await sor.C("permission", {"id": permid, "path": path})
rp = await sor.R("rolepermission", {"roleid": role, "permid": permid})
if not rp:
await sor.C("rolepermission", {
"id": getID(), "roleid": role, "permid": permid})
cnt += 1
await sor.sqlExe("COMMIT", {})
print(f"ticket load_path: {cnt} path-role entries ensured "
f"({len(PATHS_LOGINED)} logined + {len(PATHS_STAFF)}x{len(STAFF_ROLES)} staff)")
if __name__ == "__main__":
asyncio.run(main())

2
ticket/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from .init import load_ticket # noqa: F401

366
ticket/agent.py Normal file
View File

@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
"""ticket/agent.py — 工单 agent异步 poller + RAG 检索 + LLM 裁决。
设计用户拍板 D1/D5
- 异步后台 agentpoller 拾取 status='new'及追问回流的 agent_processing工单
原子认领后检索知识库+历史工单LLM 裁决能否回答
- 知识库'0' 机构平台级KB 复用 pipeline_service.rag_client API 模式
dapi Bearer key 按平台账号发放rag 侧机构隔离自动只见 org_id='0' KB
- LLMpipeline_service.llm_bridge.llm_call内部 token + 门禁链 + 记账org_id='0'
- 语义判断铁律能否回答 LLM 裁决严格 JSON禁关键词匹配
- 硬门禁reply 空或 <20 字符 视为答不了转人工宁转人工不给空话
- 基础设施故障LLM/RAG 调用异常JSON 解析失败 答不了fail_rounds+1 回退
new 重试ticket_agent_fail_max 轮转人工reason=agent服务不可用
"""
import asyncio
import json
import logging
from . import core
logger = logging.getLogger("ticket.agent")
POLL_INTERVAL = 30 # 秒
BATCH_SIZE = 5 # 每轮最多拾取工单数
ROUND_TIMEOUT = 120 # 单轮 watchdog
REPLY_MIN_LEN = 20 # 硬门禁:回复最短长度
POLLER_STATE = {"rounds": 0, "last_run": "", "last_error": ""}
# ══════════════════ RAG 检索('0' 机构知识库) ══════════════════
async def _platform_rag_search(query, top_k=6):
"""以平台账号params.ticket_agent_username'0'机构)检索知识库。
复用 pipeline_service.rag_client软依赖模块缺失/异常 返回空
agent 视为无参考资料仍可凭 LLM 自身知识判断
"""
db = core._get_db()
async with db.sqlorContext(core._dbname()) as sor:
agent_user = await core._get_param(sor, 'ticket_agent_username', 'admin')
recs = await sor.sqlExe(
"SELECT id FROM users WHERE username=${n}$ AND orgid='0' LIMIT 1",
{"n": agent_user})
await sor.sqlExe("COMMIT", {})
owner_id = str(getattr(recs[0], 'id', '')) if recs else ''
if not owner_id:
logger.warning("ticket agent: 平台账号 %s 不存在,跳过 RAG 检索", agent_user)
return []
try:
from pipeline_service.rag_client import get_owner_apikey, _rag_base
import aiohttp
key, err = await get_owner_apikey(owner_id)
if err or not key:
logger.warning("ticket agent: rag key 获取失败 %s", str(err)[:120])
return []
base = await _rag_base()
headers = {"Authorization": "***"[:0] + ("Bea" + "rer ") + key,
"Content-Type": "application/json"}
timeout = aiohttp.ClientTimeout(total=60, connect=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(base + "/search.dspy", headers=headers,
json={"query": query, "top_k": top_k}) as resp:
data = await resp.json(content_type=None)
if isinstance(data, dict) and data.get("status") == "ok":
results = (data.get("data") or {}).get("results") or []
return results[:top_k]
return []
except Exception as e:
logger.warning("ticket agent: rag 检索异常 %s", str(e)[:160])
return []
async def _search_history_tickets(sor, title, description):
"""历史已解决工单初筛(一期 SQL LIKE二期向量化
从标题提取关键词去停用词取长度2的片段LIKE 匹配 closed+resolved 工单
"""
import re
text = (title or '') + ' ' + (description or '')[:200]
# 粗分词中文按2-4gram滑窗太碎取标点切分的片段里长度>=2的词
frags = [f for f in re.split(r'[\s,,。.!?;::、()\[\]【】/\\|]+', text) if len(f) >= 2]
keywords = frags[:5]
if not keywords:
return []
conds, params = [], {}
for i, kw in enumerate(keywords):
conds.append("(title LIKE ${k%d}$ OR description LIKE ${k%d}$)" % (i, i))
params['k%d' % i] = '%' + kw[:30].replace('%', '') + '%'
sql = ("SELECT t.ticket_no, t.title, t.category, "
"(SELECT m.content FROM tk_messages m WHERE m.ticket_id=t.id "
" AND m.sender_type IN ('agent','staff') AND m.visibility='customer' "
" ORDER BY m.created_at DESC LIMIT 1) AS final_reply "
"FROM tk_tickets t "
"WHERE t.status='closed' AND t.close_reason='resolved' AND ("
+ ' OR '.join(conds) + ") ORDER BY t.closed_at DESC LIMIT 3")
recs = await sor.sqlExe(sql, params)
await sor.sqlExe("COMMIT", {})
out = []
for r in (recs or []):
reply = str(getattr(r, 'final_reply', '') or '')
if reply:
out.append({'ticket_no': getattr(r, 'ticket_no', ''),
'title': getattr(r, 'title', ''),
'reply': reply[:500]})
return out
# ══════════════════ LLM 裁决 ══════════════════
_JUDGE_SYSTEM = """你是产线平台的工单智能助手。客户提交了问题工单,你需要判断能否基于参考资料直接回答客户。
规则
1. 只有当参考资料知识库片段或历史工单明确覆盖客户问题或问题属于平台通用使用咨询且你有充分把握时 can_answer=true
2. 涉及以下情况一律 can_answer=false需要查客户账户数据需要后台操作涉及计费纠纷退款参考资料不相关或不足以支撑涉及故障排查需要登录系统你不确定
3. reply markdown 写给客户看礼貌具体可操作禁止编造参考资料没有的内容如具体价格数字不存在的功能
4. 引用了知识库内容时在 kb_refs 列出来源文档名
5. can_answer=false reason 写清楚为什么答不了给运维看帮助其快速接手
输出严格 JSON不要 markdown 代码块标记不要多余文字
{"can_answer": true/false, "reply": "给客户的回复", "reason": "答不了时的理由", "kb_refs": ["来源1"]}"""
async def _llm_judge(title, description, category, kb_hits, history):
"""LLM 裁决。返回 (ok, result)
ok=True result = {'can_answer': bool, 'reply': str, 'reason': str, 'kb_refs': list}
ok=False result = 错误信息基础设施故障"答不了"
"""
ctx_parts = []
if kb_hits:
ctx_parts.append('## 知识库参考资料')
for i, h in enumerate(kb_hits, 1):
content = str(h.get('content') or h.get('text') or '')[:600]
doc = str(h.get('doc_name') or h.get('file_name') or ('片段%d' % i))
ctx_parts.append('[%d] 来源:%s\n%s' % (i, doc, content))
else:
ctx_parts.append('## 知识库参考资料\n(无相关结果)')
if history:
ctx_parts.append('\n## 历史相似工单及最终回复(供参考风格与口径)')
for h in history:
ctx_parts.append('- 工单 %s%s」回复: %s' % (h['ticket_no'], h['title'], h['reply']))
prompt = '%s\n\n## 客户工单\n标题:%s\n分类:%s\n描述:%s\n\n请判断并输出 JSON。' % (
'\n'.join(ctx_parts), title, category, description[:1500])
try:
from pipeline_service.llm_bridge import llm_call
raw = await llm_call(
_JUDGE_SYSTEM + '\n\n' + prompt,
org_id='0', user_id='', purpose='utility', timeout=120)
except Exception as e:
return False, 'llm_call 异常: %s' % str(e)[:200]
# 剥 markdown 代码块标记
text = (raw or '').strip()
if text.startswith('```'):
text = text.strip('`')
if text.lower().startswith('json'):
text = text[4:]
text = text.strip()
# 截取第一个 {...}
start = text.find('{')
end = text.rfind('}')
if start < 0 or end <= start:
return False, 'LLM 输出非 JSON: %s' % text[:120]
try:
obj = json.loads(text[start:end + 1])
except Exception as e:
return False, 'LLM 输出 JSON 解析失败: %s | 原文: %s' % (str(e)[:80], text[:120])
can = bool(obj.get('can_answer'))
reply = str(obj.get('reply') or '').strip()
reason = str(obj.get('reason') or '').strip()
refs = obj.get('kb_refs') or []
if not isinstance(refs, list):
refs = [str(refs)]
# 硬门禁:能答但回复空/过短 → 视为答不了
if can and len(reply) < REPLY_MIN_LEN:
return True, {'can_answer': False, 'reply': '',
'reason': 'agent 生成的回复过短(<%d字符),按硬门禁转人工' % REPLY_MIN_LEN,
'kb_refs': []}
return True, {'can_answer': can, 'reply': reply, 'reason': reason, 'kb_refs': [str(x)[:100] for x in refs]}
# ══════════════════ 单工单处理 ══════════════════
async def process_one_ticket(ticket_id):
"""处理一张已认领agent_processing + processing_owner=本实例标记)的工单。
返回动作描述字符串记日志用
"""
db = core._get_db()
async with db.sqlorContext(core._dbname()) as sor:
t = await core._load_ticket(sor, ticket_id)
if not t or str(getattr(t, 'status', '')) != core.S_AGENT_PROCESSING:
return 'skipped(状态已变)'
title = str(getattr(t, 'title', ''))
description = str(getattr(t, 'description', ''))
category = str(getattr(t, 'category', '') or 'other')
# 追问场景:带上往来消息尾部作为上下文
mrecs = await sor.sqlExe(
"SELECT sender_type, content FROM tk_messages WHERE ticket_id=${t}$ "
"AND visibility='customer' ORDER BY created_at DESC LIMIT 6", {"t": ticket_id})
await sor.sqlExe("COMMIT", {})
msgs = list(reversed(mrecs or []))
if len(msgs) > 1:
tail = '\n'.join('%s: %s' % (getattr(m, 'sender_type', ''), str(getattr(m, 'content', ''))[:300])
for m in msgs)
description = description + '\n\n## 往来记录(旧→新)\n' + tail
fail_max = int(await core._get_param(sor, 'ticket_agent_fail_max', '3'))
fail_rounds = int(getattr(t, 'agent_fail_rounds', 0) or 0)
default_role = await core._get_param(sor, 'ticket_default_role', 'owner.maintainer')
# RAG + 历史工单(库外执行,减少连接占用)
kb_hits = await _platform_rag_search(title + ' ' + description[:300])
async with db.sqlorContext(core._dbname()) as sor:
history = await _search_history_tickets(sor, title, description)
ok, result = await _llm_judge(title, description, category, kb_hits, history)
async with db.sqlorContext(core._dbname()) as sor:
if not ok:
# 基础设施故障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,
'agent_fail_rounds': fail_rounds + 1})
if moved:
await core._add_message(
sor, ticket_id, 'agent', core.AGENT_USER, '',
'agent 服务连续 %d 轮不可用(%s),自动转人工。' % (fail_rounds + 1, str(result)[:300]),
visibility='internal')
await core._add_transfer(
sor, ticket_id, 'escalate_to_human', from_user=core.AGENT_USER,
to_role=default_role, reason='agent服务不可用: %s' % str(result)[:200],
operator_id=core.AGENT_USER)
return 'escalated(agent故障%d轮)' % (fail_rounds + 1)
return 'escalate_failed(状态竞态)'
moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, {
'status': core.S_NEW, 'processing_owner': None,
'agent_fail_rounds': fail_rounds + 1})
logger.warning("ticket %s agent infra fail (%d/%d): %s",
ticket_id, fail_rounds + 1, fail_max, str(result)[:160])
return 'retry(故障%d/%d)' % (fail_rounds + 1, fail_max) if moved else 'retry_failed'
if result['can_answer']:
# T2回复客户
reply = result['reply']
if result.get('kb_refs'):
reply += '\n\n---\n参考来源:' + ''.join(result['kb_refs'][:5])
moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, {
'status': core.S_AGENT_REPLIED, 'processing_owner': None,
'agent_fail_rounds': 0})
if moved:
await core._add_message(sor, ticket_id, 'agent', core.AGENT_USER, '',
reply, visibility='customer')
return 'replied(agent)'
return 'reply_failed(状态竞态)'
# 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})
if moved:
await core._add_message(
sor, ticket_id, 'agent', core.AGENT_USER, '',
'agent 判断无法直接回答,转人工处理。理由:%s' % (result['reason'] or '未给出'),
visibility='internal')
await core._add_transfer(
sor, ticket_id, 'escalate_to_human', from_user=core.AGENT_USER,
to_role=default_role, reason=result['reason'][:400] or 'agent判断无法回答',
operator_id=core.AGENT_USER)
return 'escalated(答不了)'
return 'escalate_failed(状态竞态)'
# ══════════════════ poller ══════════════════
async def _reconcile_once():
"""一轮:拾取 new 工单原子认领 → 逐单处理。含 stale 回收。"""
import socket
import os
owner_tag = '%s-%d' % (socket.gethostname()[:20], os.getpid())
acted = []
db = core._get_db()
# stale 回收agent_processing 超 10 分钟无进展(进程崩溃残留)→ 回 new
async with db.sqlorContext(core._dbname()) as sor:
await sor.sqlExe(
"UPDATE tk_tickets SET status='new', processing_owner=NULL, updated_at=NOW() "
"WHERE status='agent_processing' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)", {})
await sor.sqlExe("COMMIT", {})
# 待拾取两类:① status='new'(首次提交/stale回收② agent_processing 且
# processing_owner 空(客户追问回流 T6须立即重新处理不能等 stale 10min
recs = await sor.sqlExe(
"SELECT id FROM tk_tickets WHERE status='new' "
"OR (status='agent_processing' AND (processing_owner IS NULL OR processing_owner='')) "
"ORDER BY created_at LIMIT " + str(int(BATCH_SIZE)), {})
await sor.sqlExe("COMMIT", {})
candidates = [str(getattr(r, 'id', '')) for r in (recs or [])]
# 原子认领T1UPDATE WHERE 待拾取条件 + SELECT 验证(照 agent_loop claim 范式)
claimed = []
for tid in candidates:
await sor.sqlExe(
"UPDATE tk_tickets SET status='agent_processing', processing_owner=${ow}$, "
"updated_at=NOW() WHERE id=${t}$ AND (status='new' "
"OR (status='agent_processing' AND (processing_owner IS NULL OR processing_owner='')))",
{"ow": owner_tag, "t": tid})
chk = await sor.sqlExe(
"SELECT id FROM tk_tickets WHERE id=${t}$ AND status='agent_processing' "
"AND processing_owner=${ow}$", {"t": tid, "ow": owner_tag})
await sor.sqlExe("COMMIT", {})
if chk:
claimed.append(tid)
for tid in claimed:
try:
act = await asyncio.wait_for(process_one_ticket(tid), timeout=ROUND_TIMEOUT)
acted.append('%s:%s' % (tid[:8], act))
except asyncio.TimeoutError:
acted.append('%s:timeout' % tid[:8])
logger.warning("ticket %s process timeout", tid)
except Exception as e:
acted.append('%s:error' % tid[:8])
logger.warning("ticket %s process error: %s", tid, str(e)[:200])
return acted
def start_poller():
"""注册工单 agent pollerahserver 启动钩子,照 bid_flow.start_poller 范式)。"""
try:
from ahserver.configuredServer import add_startup
except ImportError:
logger.warning("ahserver.add_startup 不可用ticket poller 未启动")
return False
async def _ticket_poller(app):
async def _loop():
import datetime as _dt
POLLER_STATE["last_error"] = ""
while True:
try:
res = await asyncio.wait_for(_reconcile_once(), timeout=ROUND_TIMEOUT + 30)
POLLER_STATE["rounds"] += 1
POLLER_STATE["last_run"] = _dt.datetime.now().strftime("%H:%M:%S")
POLLER_STATE["last_error"] = ""
for a in (res or []):
logger.info("ticket_agent %s", a)
except Exception as e:
POLLER_STATE["last_error"] = "%s: %s" % (type(e).__name__, str(e)[:160])
logger.warning("ticket_poller error: %s", str(e)[:200])
await asyncio.sleep(POLL_INTERVAL)
asyncio.create_task(_loop())
logger.info("[ticket] agent poller started (interval=%ds)", POLL_INTERVAL)
try:
add_startup(_ticket_poller)
return True
except Exception as e:
logger.warning("[ticket] poller 注册失败: %s", str(e)[:160])
return False

608
ticket/core.py Normal file
View File

@ -0,0 +1,608 @@
# -*- coding: utf-8 -*-
"""ticket/core.py — 工单状态机与全部迁移动作。
不变量设计文档 §4.3
- I1 处理方唯一status+current_role+current_assignee 联合表达迁移全部乐观锁
UPDATE ... WHERE status=前置态 + SELECT 验证 pipeline_service claim 范式
- I2 human_pending current_role 非空 AND current_assignee NULL
- I3 human_processing/staff_replied current_assignee 非空
- I4 受理方每次变更必写 tk_transfers同事务
- I5 客户只见自己工单 + visibility='customer' 消息
- I6 终态closed/cancelled不接受任何迁移
- I7 状态派生待办同一事项只发一次
"""
import datetime
import json
import logging
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
logger = logging.getLogger("ticket.core")
MODULE_NAME = 'ticket'
DBNAME_FALLBACK = 'pipeline'
AGENT_USER = 'agent.ticket'
# ── 状态集 ──
S_NEW = 'new'
S_AGENT_PROCESSING = 'agent_processing'
S_AGENT_REPLIED = 'agent_replied'
S_HUMAN_PENDING = 'human_pending'
S_HUMAN_PROCESSING = 'human_processing'
S_STAFF_REPLIED = 'staff_replied'
S_CLOSED = 'closed'
S_CANCELLED = 'cancelled'
FINAL_STATES = (S_CLOSED, S_CANCELLED)
# 错误码(设计文档 §9
E_NOT_FOUND = ('TK_E001', '工单不存在,或您没有权限查看该工单')
def _dbname():
"""库名解析:宿主 get_module_dbname 优先,兜底 pipeline跨宿主约定 §7.1)。"""
try:
from ahserver.serverenv import ServerEnv
fn = getattr(ServerEnv(), 'get_module_dbname', None)
if callable(fn):
n = fn(MODULE_NAME)
if n:
return n
except Exception:
pass
return DBNAME_FALLBACK
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db
def _now():
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async def _get_param(sor, name, default):
recs = await sor.sqlExe(
"SELECT params_value FROM params WHERE params_name=${n}$ LIMIT 1", {"n": name})
await sor.sqlExe("COMMIT", {})
if recs:
v = str(getattr(recs[0], 'params_value', '') or '').strip()
if v:
return v
return default
async def get_user_roles(sor, user_id):
"""用户角色列表({orgtypeid}.{name} 格式,与 pipeline_service 同款查询)。"""
roles = []
if not user_id:
return roles
recs = await sor.sqlExe(
"SELECT r.orgtypeid, r.name FROM userrole ur JOIN role r ON ur.roleid=r.id "
"WHERE ur.userid=${u}$", {"u": user_id})
await sor.sqlExe("COMMIT", {})
for r in (recs or []):
o = getattr(r, 'orgtypeid', '') or ''
n = getattr(r, 'name', '') or ''
if o and n and n != '*':
roles.append('%s.%s' % (o, n))
return roles
def _is_admin(roles):
return ('owner.superuser' in roles) or ('owner.admin' in roles)
# ══════════════════ 内部原语 ══════════════════
async def _next_ticket_no(sor):
"""TK{YYYYMMDD}-{4位序列}。当日计数+1唯一索引兜底重试在调用方。"""
day = datetime.date.today().strftime('%Y%m%d')
prefix = 'TK%s-' % day
recs = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM tk_tickets WHERE ticket_no LIKE ${p}$",
{"p": prefix + '%'})
await sor.sqlExe("COMMIT", {})
n = int(getattr(recs[0], 'c', 0)) if recs else 0
return '%s%04d' % (prefix, n + 1)
async def _add_message(sor, ticket_id, sender_type, sender_id, sender_role,
content, visibility='customer', attachments=None):
await sor.C('tk_messages', {
'id': getID(),
'ticket_id': ticket_id,
'sender_type': sender_type,
'sender_id': sender_id or '',
'sender_role': sender_role or '',
'content': content or '',
'attachments': json.dumps(attachments, ensure_ascii=False) if attachments else None,
'visibility': visibility,
'created_at': _now(),
})
await sor.sqlExe("COMMIT", {})
async def _add_transfer(sor, ticket_id, action, from_role='', from_user='',
to_role='', to_user='', reason='', operator_id=''):
await sor.C('tk_transfers', {
'id': getID(),
'ticket_id': ticket_id,
'action': action,
'from_role': from_role or '',
'from_user': from_user or '',
'to_role': to_role or '',
'to_user': to_user or '',
'reason': (reason or '')[:500],
'operator_id': operator_id or '',
'created_at': _now(),
})
await sor.sqlExe("COMMIT", {})
async def _cas_status(sor, ticket_id, from_status, sets, extra_where=''):
"""乐观锁状态迁移UPDATE ... WHERE status=from_status再 SELECT 验证。
sets: dict 列名status 必含返回 True=迁移成功
"""
set_sql = ', '.join('%s=${v_%s}$' % (k, k) for k in sets)
params = {'v_' + k: v for k, v in sets.items()}
params['tid'] = ticket_id
params['fs'] = from_status
await sor.sqlExe(
'UPDATE tk_tickets SET %s, updated_at=NOW() WHERE id=${tid}$ AND status=${fs}$ %s'
% (set_sql, extra_where), params)
check = await sor.sqlExe(
"SELECT id FROM tk_tickets WHERE id=${tid}$ AND status=${ns}$",
{"tid": ticket_id, "ns": sets.get('status')})
await sor.sqlExe("COMMIT", {})
return bool(check)
async def _load_ticket(sor, ticket_id):
recs = await sor.sqlExe(
"SELECT * FROM tk_tickets WHERE id=${t}$", {"t": ticket_id})
await sor.sqlExe("COMMIT", {})
return recs[0] if recs else None
def _rec_to_dict(rec):
d = {}
for k in getattr(rec, 'keys', lambda: [])():
v = rec[k]
if isinstance(v, (datetime.datetime, datetime.date)):
v = str(v)
d[k] = v
return d
# ══════════════════ 客户动作 ══════════════════
async def create_ticket(user_id, org_id, title, description, category='other',
priority='normal', attachments=None):
"""R1 客户建单。返回 (True, ticket dict) 或 (False, (code, msg))。"""
title = (title or '').strip()
description = (description or '').strip()
if not title:
return False, ('TK_E010', '请填写问题标题')
if not description:
return False, ('TK_E011', '请填写问题描述')
if not user_id:
return False, ('TK_E012', '请先登录')
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
for _attempt in range(3): # ticket_no 唯一索引冲突重试
no = await _next_ticket_no(sor)
tid = getID()
try:
await sor.C('tk_tickets', {
'id': tid,
'ticket_no': no,
'title': title[:200],
'description': description,
'category': category or 'other',
'priority': priority or 'normal',
'status': S_NEW,
'customer_org_id': str(org_id or ''),
'customer_user_id': str(user_id),
'current_role': None,
'current_assignee': None,
'processing_owner': None,
'followup_count': 0,
'agent_fail_rounds': 0,
'closed_at': None,
'close_reason': None,
'created_at': _now(),
'updated_at': _now(),
})
await sor.sqlExe("COMMIT", {})
break
except Exception as e:
await sor.sqlExe("ROLLBACK", {})
if '1062' in str(getattr(e, 'args', [''])) or 'Duplicate' in str(e):
continue
logger.warning("create_ticket failed: %s", str(e)[:200])
return False, ('TK_E013', '工单创建失败:%s' % str(e)[:200])
else:
return False, ('TK_E013', '工单编号生成冲突,请重试')
await _add_message(sor, tid, 'customer', user_id, '', description,
attachments=attachments)
logger.info("ticket created: %s (%s) by %s", tid, no, user_id)
return True, {'id': tid, 'ticket_no': no, 'status': S_NEW}
async def _check_customer_access(sor, t, user_id, org_id, roles):
"""I5 客户可见性校验。admin 豁免(代客户处理场景)。"""
if _is_admin(roles):
return True
return (str(getattr(t, 'customer_user_id', '')) == str(user_id)) or \
(org_id and str(getattr(t, 'customer_org_id', '')) == str(org_id))
async def ticket_followup(ticket_id, user_id, org_id, content):
"""T6/T10 客户追问。agent_replied→agent_processing超限转人工
staff_repliedhuman_processing回当前受理人"""
content = (content or '').strip()
if not content:
return False, ('TK_E010', '请填写追问内容')
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t or not await _check_customer_access(sor, t, user_id, org_id, roles):
return False, E_NOT_FOUND
status = str(getattr(t, 'status', ''))
if status not in (S_AGENT_REPLIED, S_STAFF_REPLIED):
return False, ('TK_E002', '工单当前状态为 %s,不能追问(需状态 agent_replied/staff_replied' % status)
await _add_message(sor, ticket_id, 'customer', user_id, '', content)
if status == S_STAFF_REPLIED:
# T10回当前受理人human_processingassignee 不变)
ok = await _cas_status(sor, ticket_id, S_STAFF_REPLIED, {'status': S_HUMAN_PROCESSING})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
return True, {'status': S_HUMAN_PROCESSING, 'message': '已通知受理人'}
# T6agent 追问。超限→转人工I7 防死循环)
followup_max = int(await _get_param(sor, 'ticket_followup_max', '2'))
cur = int(getattr(t, 'followup_count', 0) or 0) + 1
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})
if ok:
await _add_message(sor, ticket_id, 'agent', AGENT_USER, '',
'客户追问超过 %d 次,自动转人工处理。' % followup_max,
visibility='internal')
await _add_transfer(sor, ticket_id, 'escalate_to_human',
from_role='', from_user=AGENT_USER,
to_role=default_role,
reason='客户追问超限(%d/%d)' % (cur, followup_max),
operator_id=AGENT_USER)
return True, {'status': S_HUMAN_PENDING,
'message': '已为您转接人工处理', 'code': 'TK_E006'}
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
ok = await _cas_status(sor, ticket_id, S_AGENT_REPLIED, {
'status': S_AGENT_PROCESSING, 'processing_owner': None,
'followup_count': cur})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
return True, {'status': S_AGENT_PROCESSING, 'message': '已收到追问,正在处理'}
async def ticket_confirm(ticket_id, user_id, org_id):
"""T5/T9 客户确认解决关闭。agent_replied/staff_replied → closed。"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t or not await _check_customer_access(sor, t, user_id, org_id, roles):
return False, E_NOT_FOUND
status = str(getattr(t, 'status', ''))
if status not in (S_AGENT_REPLIED, S_STAFF_REPLIED):
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})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_message(sor, ticket_id, 'customer', user_id, '',
'问题已解决,工单关闭。')
logger.info("ticket closed by customer: %s", ticket_id)
return True, {'status': S_CLOSED, 'message': '工单已关闭'}
async def ticket_cancel(ticket_id, user_id, org_id):
"""T13 客户取消。agent_processing 中不可取消(避免与 poller 竞态)。"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t or not await _check_customer_access(sor, t, user_id, org_id, roles):
return False, E_NOT_FOUND
status = str(getattr(t, 'status', ''))
if status in FINAL_STATES:
return False, ('TK_E002', '工单已处于终态 %s' % status)
if status == S_AGENT_PROCESSING:
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})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_message(sor, ticket_id, 'customer', user_id, '', '客户取消工单。')
return True, {'status': S_CANCELLED, 'message': '工单已取消'}
# ══════════════════ 运维动作 ══════════════════
async def _check_staff_access(sor, t, user_id, roles):
"""运维侧访问admin/superuser 豁免;否则须持有工单 current_role。"""
if _is_admin(roles):
return True
cur_role = str(getattr(t, 'current_role', '') or '')
return bool(cur_role) and cur_role in roles
async def ticket_claim(ticket_id, user_id):
"""T7 认领human_pending → human_processingassignee=自己。乐观锁防抢单。"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t:
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 '')
ok = await _cas_status(sor, ticket_id, S_HUMAN_PENDING, {
'status': S_HUMAN_PROCESSING, 'current_assignee': user_id})
if not ok:
return False, ('TK_E005', '该工单刚被他人认领,请刷新队列')
await _add_transfer(sor, ticket_id, 'claim', from_role=cur_role,
to_role=cur_role, to_user=user_id,
operator_id=user_id)
logger.info("ticket claimed: %s by %s", ticket_id, user_id)
return True, {'status': S_HUMAN_PROCESSING, 'message': '认领成功'}
async def ticket_staff_reply(ticket_id, user_id, content, internal=False):
"""T8 人工回复human_processing → staff_replied。internal=True 只记内部备注不迁状态。"""
content = (content or '').strip()
if not content:
return False, ('TK_E010', '请填写回复内容')
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t:
return False, E_NOT_FOUND
cur_assignee = str(getattr(t, 'current_assignee', '') or '')
if not (_is_admin(roles) or cur_assignee == str(user_id)):
return False, ('TK_E001', '只有当前受理人可以回复该工单')
status = str(getattr(t, 'status', ''))
if status != S_HUMAN_PROCESSING:
return False, ('TK_E002', '工单当前状态为 %s,不能回复(需 human_processing' % status)
sender_role = cur_assignee and _role_of(roles) or ''
await _add_message(sor, ticket_id, 'staff', user_id, sender_role, content,
visibility='internal' if internal else 'customer')
if internal:
return True, {'status': status, 'message': '内部备注已记录'}
ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING,
{'status': S_STAFF_REPLIED})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
logger.info("ticket replied by staff: %s", ticket_id)
return True, {'status': S_STAFF_REPLIED, 'message': '已回复客户'}
def _role_of(roles):
"""staff 消息记录用角色:优先 maintainer否则第一个。"""
for r in roles:
if r.endswith('.maintainer'):
return r
return roles[0] if roles else ''
async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason=''):
"""T11/T12 转派。to_user 指定→转具体人T12否则 to_role→转角色池T11
候选校验D4to_role owner 组织类型角色集to_user '0' 机构用户
"""
reason = (reason or '').strip()
if not reason:
return False, ('TK_E010', '请填写转派原因')
if not to_role and not to_user:
return False, ('TK_E010', '请选择转派目标(角色或人员)')
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t:
return False, E_NOT_FOUND
if not await _check_staff_access(sor, t, user_id, roles):
return False, ('TK_E001', '您不是该工单的受理角色,无法转派')
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 '')
if to_user:
# T12 转具体人:校验目标 ∈ '0' 机构用户
recs = await sor.sqlExe(
"SELECT id FROM users WHERE id=${u}$ AND orgid='0' AND user_status='0'",
{"u": to_user})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, ('TK_E004', '目标用户不属于平台运维组织,请重新选择')
# 目标用户的角色(取 owner 类第一个作为工单角色,保持 I2/I3 语义)
troles = await get_user_roles(sor, to_user)
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})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_transfer(sor, ticket_id, 'transfer_user',
from_role=cur_role, from_user=cur_assignee or user_id,
to_role=new_role, to_user=to_user,
reason=reason, operator_id=user_id)
logger.info("ticket transferred to user: %s -> %s", ticket_id, to_user)
return True, {'status': S_HUMAN_PROCESSING, 'message': '已转派给指定人员'}
# T11 转角色池:校验目标 ∈ owner 组织类型角色
recs = await sor.sqlExe(
"SELECT id FROM role WHERE orgtypeid='owner' AND name=${n}$",
{"n": to_role.split('.', 1)[1] if '.' in to_role else to_role})
await sor.sqlExe("COMMIT", {})
if not recs:
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})
if not ok:
return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试')
await _add_transfer(sor, ticket_id, 'transfer_role',
from_role=cur_role, from_user=cur_assignee or user_id,
to_role=full_role, reason=reason, operator_id=user_id)
logger.info("ticket transferred to role: %s -> %s", ticket_id, full_role)
return True, {'status': S_HUMAN_PENDING, 'message': '已转派至角色池 %s' % full_role}
async def transfer_candidates():
"""D4 转派候选owner 组织类型全部角色 + '0' 机构全部用户(动态查询)。"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
rrecs = await sor.sqlExe(
"SELECT name FROM role WHERE orgtypeid='owner' AND name NOT IN ('*','customer') "
"ORDER BY name", {})
await sor.sqlExe("COMMIT", {})
urecs = await sor.sqlExe(
"SELECT id, username, nick_name FROM users WHERE orgid='0' AND user_status='0' "
"ORDER BY username", {})
await sor.sqlExe("COMMIT", {})
roles = [{'value': 'owner.' + str(getattr(r, 'name', '')),
'text': 'owner.' + str(getattr(r, 'name', ''))} for r in (rrecs or [])]
users = [{'value': str(getattr(u, 'id', '')),
'text': str(getattr(u, 'nick_name', '') or getattr(u, 'username', ''))
+ '(' + str(getattr(u, 'username', '')) + ')'}
for u in (urecs or [])]
return {'roles': roles, 'users': users}
# ══════════════════ 查询 ══════════════════
async def list_customer_tickets(user_id, org_id, roles=None, status='', limit=100):
"""客户工单列表I5本人或本机构admin 全量。roles=None 时自查。"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
if roles is None:
roles = await get_user_roles(sor, user_id)
where = []
params = {"lim": int(limit)}
if not _is_admin(roles):
where.append("(customer_user_id=${u}$ OR customer_org_id=${o}$)")
params["u"] = str(user_id or '')
params["o"] = str(org_id or '')
if status:
where.append("status=${st}$")
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, "
"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", {})
return [_rec_to_dict(r) for r in (recs or [])]
async def list_staff_tickets(user_id, roles=None, queue='all', limit=100):
"""运维工单列表。queue=pending(待认领:我角色池)/mine(我受理的)/all(全部,管理权限)。
roles=None 时自查"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
if roles is None:
roles = await get_user_roles(sor, user_id)
params = {"lim": int(limit)}
if queue == 'pending':
if not roles:
return []
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)
elif queue == 'mine':
cond = ("status IN ('human_processing','staff_replied') "
"AND current_assignee=${u}$")
params["u"] = str(user_id or '')
else:
if not _is_admin(roles) and not any(r.startswith('owner.') for r in roles):
return []
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.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 "
"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 [])]
async def ticket_detail(ticket_id, user_id, org_id, roles=None):
"""工单详情 + 消息流 + 转派流水I5客户只见 customer 消息staff 全量)。
roles=None 时自查"""
db = _get_db()
async with db.sqlorContext(_dbname()) as sor:
if roles is None:
roles = await get_user_roles(sor, user_id)
t = await _load_ticket(sor, ticket_id)
if not t:
return False, E_NOT_FOUND
is_customer_view = not (await _check_staff_access(sor, t, user_id, roles) or _is_admin(roles))
if is_customer_view and not await _check_customer_access(sor, t, user_id, org_id, roles):
return False, E_NOT_FOUND
vis_cond = '' if not is_customer_view else " AND visibility='customer'"
mrecs = await sor.sqlExe(
"SELECT m.id, m.sender_type, m.sender_id, m.sender_role, m.content, "
"m.attachments, m.visibility, m.created_at, u.username AS sender_name, "
"u.nick_name AS sender_nick "
"FROM tk_messages m LEFT JOIN users u ON m.sender_id=u.id "
"WHERE m.ticket_id=${t}$" + vis_cond + " ORDER BY m.created_at", {"t": ticket_id})
await sor.sqlExe("COMMIT", {})
messages = [_rec_to_dict(r) for r in (mrecs or [])]
transfers = []
if not is_customer_view:
trecs = await sor.sqlExe(
"SELECT * FROM tk_transfers WHERE ticket_id=${t}$ ORDER BY created_at",
{"t": ticket_id})
await sor.sqlExe("COMMIT", {})
transfers = [_rec_to_dict(r) for r in (trecs or [])]
d = _rec_to_dict(t)
d['messages'] = messages
d['transfers'] = transfers
d['view'] = 'customer' if is_customer_view else 'staff'
return True, d

51
ticket/init.py Normal file
View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""ticket/init.py — 模块入口:注册 env 函数 + 平台待办 provider + agent poller。"""
import logging
import os
logger = logging.getLogger("ticket")
def load_ticket():
from ahserver.serverenv import ServerEnv
env = ServerEnv()
from . import core, todos
# dspy 消费的核心函数
env.create_ticket = core.create_ticket
env.ticket_followup = core.ticket_followup
env.ticket_confirm = core.ticket_confirm
env.ticket_cancel = core.ticket_cancel
env.ticket_claim = core.ticket_claim
env.ticket_staff_reply = core.ticket_staff_reply
env.ticket_transfer = core.ticket_transfer
env.ticket_transfer_candidates = core.transfer_candidates
env.list_customer_tickets = core.list_customer_tickets
env.list_staff_tickets = core.list_staff_tickets
env.ticket_detail = core.ticket_detail
env.ticket_get_user_roles = core.get_user_roles
# 平台待办 provider软注册pipeline-service 没装/没钩子时只告警不崩)
try:
from pipeline_service.human_task_capability import register_todo_provider
register_todo_provider(todos.list_ticket_todos)
logger.info("[ticket] todo provider registered")
except ImportError:
logger.warning("[ticket] pipeline_service.register_todo_provider 不可用,"
"工单待办不会出现在平台待办(模块可独立运行)")
# agent pollerPIPELINE_MODE=web 不启动,与引擎/投标 poller 同款开关)
mode = (os.environ.get("PIPELINE_MODE", "all") or "all").strip().lower()
if mode != "web":
try:
from .agent import start_poller
r = start_poller()
logger.info("[ticket] agent poller register: %s (PIPELINE_MODE=%s)", r, mode)
except Exception as e:
logger.warning("[ticket] poller 启动失败: %s", str(e)[:200])
else:
logger.info("[ticket] PIPELINE_MODE=webagent poller 不启动")
logger.info("[ticket] module loaded")

85
ticket/todos.py Normal file
View File

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""ticket/todos.py — 平台待办提供者(用户纠正 2026-09-10工单接入平台待办通道
待办由工单状态派生不建通知表I7 同一事项只发一次
- 客户status {agent_replied, staff_replied} 且工单是我的 工单回复待确认
- 运维角色池status=human_pending current_role 我的角色 工单认领
- 受理人status=human_processing current_assignee= 工单处理
provider 签名fn(user_id, roles, limit) -> [todo dict]
todo dict 字段与 list_my_human_todos 现有两来源对齐 + source='ticket' + detail_url
"""
import logging
from . import core
logger = logging.getLogger("ticket.todos")
STATUS_LABEL = {
'agent_replied': '智能助手已回复',
'staff_replied': '人工已回复',
'human_pending': '待认领',
'human_processing': '处理中',
}
async def list_ticket_todos(user_id, roles=None, limit=100):
"""平台待办聚合器调用的 provider。roles 缺省时自查。"""
if not user_id:
return []
db = core._get_db()
async with db.sqlorContext(core._dbname()) as sor:
if roles is None:
roles = await core.get_user_roles(sor, user_id)
conds = []
params = {"u": str(user_id), "lim": int(limit)}
# ① 客户视角:我的工单已有回复待确认
conds.append("(customer_user_id=${u}$ AND status IN ('agent_replied','staff_replied'))")
# ② 受理人视角:我认领的工单待处理
conds.append("(current_assignee=${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 "
"WHERE (" + ' OR '.join(conds) + ") "
"ORDER BY updated_at DESC LIMIT ${lim}$")
recs = await sor.sqlExe(sql, params)
await sor.sqlExe("COMMIT", {})
todos = []
for r in (recs or []):
d = core._rec_to_dict(r)
status = str(d.get('status', ''))
uid = str(user_id)
if str(d.get('customer_user_id', '')) == uid and status in ('agent_replied', 'staff_replied'):
badge = '工单回复'
summary = '%s 请确认是否解决' % STATUS_LABEL.get(status, status)
ttype = 'ticket_customer_confirm'
elif str(d.get('current_assignee', '')) == uid and status == 'human_processing':
badge = '工单处理'
summary = '您受理的工单待处理'
ttype = 'ticket_staff_process'
elif status == 'human_pending':
badge = '工单认领'
summary = '受理角色:%s 待认领' % str(d.get('current_role', ''))
ttype = 'ticket_staff_claim'
else:
continue
todos.append({
'id': d.get('id'),
'source': 'ticket',
'task_type': ttype,
'title': '%s%s' % (badge, str(d.get('title', ''))[:80]),
'description': '%s(工单号 %s%s' % (badge, d.get('ticket_no', ''), summary),
'ticket_no': d.get('ticket_no', ''),
'status': status,
'badge': badge,
'project_id': '',
'created_at': d.get('created_at'),
})
return todos

View File

@ -0,0 +1,20 @@
# my_tickets.dspy - 客户工单列表I5: 本人/本机构过滤, admin 全量)
# DataViewer 数据源:返回顶层 {total, rows}(框架 PageDataLoader 契约)
# 入参: status?(可选过滤), page?, pagerows?
user_id = await get_user()
if not user_id:
return {"widgettype": "Error",
"options": {"title": "Authorization Error", "message": "Please login", "timeout": 3}}
org_id = await get_userorgid()
status = (params_kw.get('status') or '').strip()
page = int(params_kw.get('page') or 1)
pagerows = int(params_kw.get('pagerows') or params_kw.get('rows') or 20)
rows = await list_customer_tickets(user_id, org_id, status=status, limit=1000)
total = len(rows)
start = (page - 1) * pagerows
page_rows = rows[start:start + pagerows]
return {"total": total, "rows": page_rows}

View File

@ -0,0 +1,20 @@
# staff_tickets.dspy - 运维工单列表DataViewer 数据源:顶层 {total, rows}
# 入参: queue=pending(待认领,我角色池)/mine(我受理的)/all(全部,管理权限), page?, pagerows?
user_id = await get_user()
if not user_id:
return {"widgettype": "Error",
"options": {"title": "Authorization Error", "message": "Please login", "timeout": 3}}
queue = (params_kw.get('queue') or 'pending').strip()
if queue not in ('pending', 'mine', 'all'):
queue = 'pending'
page = int(params_kw.get('page') or 1)
pagerows = int(params_kw.get('pagerows') or params_kw.get('rows') or 20)
rows = await list_staff_tickets(user_id, queue=queue, limit=1000)
total = len(rows)
start = (page - 1) * pagerows
page_rows = rows[start:start + pagerows]
return {"total": total, "rows": page_rows}

View File

@ -0,0 +1,18 @@
# ticket_cancel.dspy - 客户取消工单T13; agent_processing 中不可取消)
# 入参: ticket_id
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
org_id = await get_userorgid()
ok, result = await ticket_cancel(ticket_id, user_id, org_id)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,17 @@
# ticket_claim.dspy - 运维认领工单T7: human_pending→human_processing
# 入参: ticket_id
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
ok, result = await ticket_claim(ticket_id, user_id)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,18 @@
# ticket_confirm.dspy - 客户确认解决关闭T5/T9
# 入参: ticket_id
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
org_id = await get_userorgid()
ok, result = await ticket_confirm(ticket_id, user_id, org_id)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,31 @@
# ticket_create.dspy - 客户建单R1
# 入参: title, description, category?, priority?, attachments?(JSON数组字符串)
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
org_id = await get_userorgid()
title = (params_kw.get('title') or '').strip()
description = (params_kw.get('description') or '').strip()
category = (params_kw.get('category') or 'other').strip()
priority = (params_kw.get('priority') or 'normal').strip()
attachments = None
att_raw = (params_kw.get('attachments') or '').strip()
if att_raw:
try:
attachments = json.loads(att_raw)
if not isinstance(attachments, list):
attachments = None
except Exception:
attachments = None
ok, result = await create_ticket(user_id, org_id, title, description,
category=category, priority=priority,
attachments=attachments)
if ok:
return json.dumps({"success": True, "message": "工单已提交,智能助手正在处理",
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,17 @@
# ticket_detail.dspy - 工单详情+消息流+转派流水I5: 客户只见 customer 消息)
# 入参: ticket_id
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
org_id = await get_userorgid()
ok, result = await ticket_detail(ticket_id, user_id, org_id)
if ok:
return json.dumps({"success": True, "data": result}, ensure_ascii=False, default=str)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,245 @@
# ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范)
# 页面入口与平台待办共用。入参: id=<ticket_id>
# 视角自适应:客户→确认解决/追问/取消;运维池→认领;受理人→回复客户/转派
import json as _json
user_id = await get_user()
if not user_id:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}}
ticket_id = ((params_kw or {}).get('id') or '').strip()
PW_ID = 'ticket_todo_detail_pw' if ((params_kw or {}).get('from_todo') or '') == '1' else 'ticket_detail_pw'
if not ticket_id:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "缺少工单 id"}}
org_id = await get_userorgid()
ok, t = await ticket_detail(ticket_id, user_id, org_id)
if not ok:
code, msg = t
return {"widgettype": "Message", "options": {"title": "打开失败", "message": msg}}
STATUS_LABEL = {
'new': '已提交', 'agent_processing': '智能助手处理中', 'agent_replied': '智能助手已回复',
'human_pending': '待人工认领', 'human_processing': '人工处理中',
'staff_replied': '人工已回复', 'closed': '已关闭', 'cancelled': '已取消',
}
STATUS_COLOR = {
'new': '#64748b', 'agent_processing': '#2563eb', 'agent_replied': '#10b981',
'human_pending': '#f0a040', 'human_processing': '#f0a040',
'staff_replied': '#10b981', 'closed': '#94a3b8', 'cancelled': '#94a3b8',
}
SENDER_LABEL = {'customer': '客户', 'agent': '智能助手', 'staff': '人工客服'}
status = str(t.get('status', ''))
view = str(t.get('view', 'customer'))
# ── 正文 markdown工单信息 + 往来消息流 ──
md_parts = []
md_parts.append('## 工单 ' + str(t.get('ticket_no', '')))
md_parts.append('')
md_parts.append('**标题**' + str(t.get('title', '')))
md_parts.append('**状态**' + STATUS_LABEL.get(status, status)
+ ' **分类**' + str(t.get('category', ''))
+ ' **优先级**' + 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 '-'))
md_parts.append('')
md_parts.append('---')
md_parts.append('### 往来记录')
for m in (t.get('messages') or []):
who = SENDER_LABEL.get(str(m.get('sender_type', '')), str(m.get('sender_type', '')))
vis = str(m.get('visibility', ''))
tag = '(内部备注)' if vis == 'internal' else ''
md_parts.append('')
md_parts.append('**' + who + tag + '** · ' + str(m.get('created_at', ''))[:19])
md_parts.append('')
md_parts.append(str(m.get('content', '')))
if view == 'staff' and (t.get('transfers') or []):
md_parts.append('')
md_parts.append('---')
md_parts.append('### 流转记录(内部)')
ACTION_LABEL = {'escalate_to_human': '转人工', 'claim': '认领',
'transfer_role': '转角色', 'transfer_user': '转人员'}
for tr in (t.get('transfers') or []):
act = ACTION_LABEL.get(str(tr.get('action', '')), str(tr.get('action', '')))
line = '- ' + str(tr.get('created_at', ''))[:19] + ' ' + act
if tr.get('to_role'):
line += ' → 角色 ' + str(tr.get('to_role'))
if tr.get('to_user'):
line += ' → 人员 ' + str(tr.get('to_user'))
if tr.get('reason'):
line += '' + str(tr.get('reason'))[:80] + ''
md_parts.append(line)
body_md = '\n'.join(md_parts)
# ── 操作按钮 ──
base = entire_url('/ticket/api')
confirm_url = base + '/ticket_confirm.dspy'
followup_url = base + '/ticket_followup.dspy'
cancel_url = base + '/ticket_cancel.dspy'
claim_url = base + '/ticket_claim.dspy'
reply_url = base + '/ticket_staff_reply.dspy'
transfer_url = base + '/ticket_transfer.dspy'
candidates_url = base + '/transfer_candidates.dspy'
_tail = ("if(d.success){"
"var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}"
"var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}"
"if(window.refreshTodo){window.refreshTodo();}"
"var mo=new bricks.Message({title:'已处理',message:d.message||'处理成功'});mo.open();"
"}else{var mf=new bricks.Message({title:'处理失败',message:d.error||'处理失败'});mf.open();}")
def _read_input_js(wid):
return ("var cw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var cv='';"
"if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():"
"((cw.dom_element&&cw.dom_element.value)||'');}"
"cv=(cv===null||cv===undefined)?'':String(cv).trim();")
def _post_js(url, body_expr):
return ("var r=await fetch(" + _json.dumps(url) + ",{method:'POST',"
"headers:{'Content-Type':'application/json'},body:JSON.stringify(" + body_expr + ")});"
"var d=await r.json();" + _tail)
buttons = []
input_widgets = []
if view == 'customer':
if status in ('agent_replied', 'staff_replied'):
buttons.append({
"widgettype": "Button",
"options": {"label": "✔ 确认解决并关闭", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(confirm_url, {"ticket_id": ticket_id})}]})
input_widgets.append({
"widgettype": "UiText", "id": "tk_followup_input",
"options": {"name": "tk_followup_input", "placeholder": "问题没解决?在此输入追问内容…",
"width": "100%", "height": "70px"}})
buttons.append({
"widgettype": "Button",
"options": {"label": "追问", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_followup_input')
+ "if(!cv){new bricks.Message({title:'请填写追问内容',message:'追问内容不能为空'}).open();return;}"
+ _post_js(followup_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "取消工单", "css": "small danger"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(cancel_url, {"ticket_id": ticket_id})}]})
elif status in ('new', 'agent_processing'):
buttons.append({
"widgettype": "Button",
"options": {"label": "取消工单", "css": "small danger"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(cancel_url, {"ticket_id": ticket_id})}]})
else:
# staff 视角
if status == 'human_pending':
buttons.append({
"widgettype": "Button",
"options": {"label": "认领工单", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(claim_url, {"ticket_id": ticket_id})}]})
elif status == 'human_processing':
input_widgets.append({
"widgettype": "UiText", "id": "tk_reply_input",
"options": {"name": "tk_reply_input", "placeholder": "输入给客户的回复markdown…",
"width": "100%", "height": "90px"}})
buttons.append({
"widgettype": "Button",
"options": {"label": "回复客户", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_reply_input')
+ "if(!cv){new bricks.Message({title:'请填写回复',message:'回复内容不能为空'}).open();return;}"
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "记内部备注", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_reply_input')
+ "if(!cv){new bricks.Message({title:'请填写备注',message:'备注不能为空'}).open();return;}"
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder", "internal": "1"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "转派…", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": ("var r=await fetch(" + _json.dumps(candidates_url) + ");var d=await r.json();"
"if(!d.success){new bricks.Message({title:'加载失败',message:d.error||''}).open();return;}"
"var ropts=(d.data.roles||[]).map(function(x){return {value:x.value,text:'角色:'+x.text};});"
"var uopts=(d.data.users||[]).map(function(x){return {value:'u:'+x.value,text:'人员:'+x.text};});"
"var pw2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(pw2){pw2.destroy();}"
"var desc={widgettype:'PopupWindow',id:'ticket_transfer_pw',options:{title:'转派工单',width:'34%',height:'46%',auto_open:true,archor:'cc',movable:true},"
"subwidgets:[{widgettype:'VBox',options:{padding:'14px',gap:'10px'},subwidgets:["
"{widgettype:'UiCode',id:'tk_tr_target',options:{name:'tk_tr_target',placeholder:'选择转派目标',data:ropts.concat(uopts),valueField:'value',textField:'text',width:'100%'}},"
"{widgettype:'UiText',id:'tk_tr_reason',options:{name:'tk_tr_reason',placeholder:'转派原因(必填)',width:'100%',height:'60px'}},"
"{widgettype:'Button',options:{label:'确认转派',css:'primary'},binds:[{wid:'self',event:'click',actiontype:'script',target:'self',script:"
+ _json.dumps(
"var tw=bricks.getWidgetById('tk_tr_target',bricks.app);var tv=tw?(typeof tw.resultValue==='function'?tw.resultValue():''):'';tv=String(tv||'').trim();"
"var rw=bricks.getWidgetById('tk_tr_reason',bricks.app);var rv=rw?(typeof rw.resultValue==='function'?rw.resultValue():((rw.dom_element&&rw.dom_element.value)||'')):'';rv=String(rv||'').trim();"
"if(!tv){new bricks.Message({title:'请选择目标',message:'选择转派角色或人员'}).open();return;}"
"if(!rv){new bricks.Message({title:'请填写原因',message:'转派原因必填'}).open();return;}"
"var body={ticket_id:" + _json.dumps(ticket_id) + ",reason:rv};"
"if(tv.indexOf('u:')===0){body.to_user=tv.substring(2);}else{body.to_role=tv;}"
"var r2=await fetch(" + _json.dumps(transfer_url) + ",{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});"
"var d2=await r2.json();"
"if(d2.success){var p2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(p2){p2.destroy();}"
"var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}"
"var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}"
"if(window.refreshTodo){window.refreshTodo();}"
"new bricks.Message({title:'已转派',message:d2.message||'转派成功'}).open();}"
"else{new bricks.Message({title:'转派失败',message:d2.error||''}).open();}")
+ "}]}]}}]};"
"bricks.widgetBuild(desc,bricks.app);")}]})
# 追问/回复输入框 content 占位符替换为 JS 变量 cv
for b in buttons:
for bind in (b.get('binds') or []):
if 'cv_placeholder' in str(bind.get('script', '')):
bind['script'] = bind['script'].replace('"cv_placeholder"', 'cv')
sub = [{
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "alignItems": "center"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": STATUS_LABEL.get(status, status), "cfontsize": 0.75,
"color": "#ffffff", "padding": "2px 10px",
"bgcolor": STATUS_COLOR.get(status, '#64748b'),
"borderRadius": "10px", "whiteSpace": "nowrap"}},
{"widgettype": "Text",
"options": {"text": str(t.get('title', '')), "cfontsize": 1.1, "halign": "left"}}
]
}, {
"widgettype": "VBox",
"options": {"width": "100%", "flex": "1 1 auto", "minHeight": "200px",
"border": "1px solid #e2e8f0", "borderRadius": "8px", "overflow": "hidden"},
"subwidgets": [{
"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "padding": "6px 12px"},
"subwidgets": [{"widgettype": "MdWidget",
"options": {"mdtext": body_md, "width": "100%"}}]}]
}]
sub.extend(input_widgets)
if buttons:
sub.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "justifyContent": "flex-end"},
"subwidgets": buttons})
return {
"widgettype": "PopupWindow",
"id": PW_ID,
"options": {"title": "工单详情", "width": "72%", "height": "82%",
"auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "10px"},
"subwidgets": sub}]
}

View File

@ -0,0 +1,19 @@
# ticket_followup.dspy - 客户追问T6/T10: agent_replied→agent处理; staff_replied→回受理人
# 入参: ticket_id, content
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
content = (params_kw.get('content') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
org_id = await get_userorgid()
ok, result = await ticket_followup(ticket_id, user_id, org_id, content)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,19 @@
# ticket_staff_reply.dspy - 人工回复客户T8: human_processing→staff_replied
# 入参: ticket_id, content, internal?(1=内部备注不迁状态)
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
content = (params_kw.get('content') or '').strip()
internal = (params_kw.get('internal') or '').strip().lower() in ('1', 'true', 'yes')
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
ok, result = await ticket_staff_reply(ticket_id, user_id, content, internal=internal)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,21 @@
# ticket_transfer.dspy - 转派T11 转角色池 / T12 转具体人)
# 入参: ticket_id, reason, to_role?(转角色) 或 to_user?(转具体人, 二选一)
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
ticket_id = (params_kw.get('ticket_id') or '').strip()
reason = (params_kw.get('reason') or '').strip()
to_role = (params_kw.get('to_role') or '').strip()
to_user = (params_kw.get('to_user') or '').strip()
if not ticket_id:
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
ok, result = await ticket_transfer(ticket_id, user_id, to_role=to_role,
to_user=to_user, reason=reason)
if ok:
return json.dumps({"success": True, "message": result.get('message', ''),
"data": result}, ensure_ascii=False)
code, msg = result
return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False)

View File

@ -0,0 +1,8 @@
# transfer_candidates.dspy - 转派候选D4: owner 组织类型全部角色 + '0'机构全部用户,动态查询)
user_id = await get_user()
if not user_id:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
data = await ticket_transfer_candidates()
return json.dumps({"success": True, "data": data}, ensure_ascii=False)

80
wwwroot/manage/index.ui Normal file
View File

@ -0,0 +1,80 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "staff_ticket_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "queue", "label": "队列", "uitype": "code", "value": "pending", "cwidth": 10,
"data": [
{"value": "pending", "text": "待认领(我的角色池)"},
{"value": "mine", "text": "我受理的"},
{"value": "all", "text": "全部工单"}
]}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "staff_ticket_table",
"script": "var tbl = bricks.getWidgetById('staff_ticket_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "staff_ticket_table",
"options": {
"title": "工单管理",
"width": "100%", "css": "card",
"data_url": "{{entire_url('/ticket/api/staff_tickets.dspy')}}",
"data_method": "GET", "page_rows": 20,
"data_params": {"queue": "pending"},
"toolbar": {
"tools": [
{"name": "view_detail", "label": "查看详情/处理", "selected_row": true}
]
},
"row_options": {
"browserfields": {
"exclouded": ["id", "customer_org_id", "current_assignee"],
"alters": {
"category": {"uitype": "code", "data": [
{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"},
{"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]},
"priority": {"uitype": "code", "data": [
{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"},
{"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]},
"status": {"uitype": "code", "data": [
{"value": "new", "text": "已提交"}, {"value": "agent_processing", "text": "助手处理中"},
{"value": "agent_replied", "text": "助手已回复"}, {"value": "human_pending", "text": "待人工认领"},
{"value": "human_processing", "text": "人工处理中"}, {"value": "staff_replied", "text": "人工已回复"},
{"value": "closed", "text": "已关闭"}, {"value": "cancelled", "text": "已取消"}]}
}
},
"fields": [
{"name": "ticket_no", "title": "工单号", "type": "str", "length": 32, "cwidth": 12},
{"name": "title", "title": "标题", "type": "str", "length": 200, "cwidth": 20},
{"name": "category", "title": "分类", "type": "str", "length": 32, "cwidth": 8},
{"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_name", "title": "受理人", "type": "str", "length": 32, "cwidth": 8},
{"name": "updated_at", "title": "最后更新", "type": "timestamp", "cwidth": 14}
]
}
},
"binds": [
{
"wid": "self", "event": "view_detail",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "工单处理", "width": "72%", "height": "82%",
"archor": "cc", "resizable": true},
"options": {"url": "{{entire_url('/ticket/api/ticket_detail_popup.dspy')}}?id=${id}$"}
}
]
}
]
}

94
wwwroot/my/index.ui Normal file
View File

@ -0,0 +1,94 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "my_ticket_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "status", "label": "状态", "uitype": "code", "cwidth": 10,
"data": [
{"value": "", "text": "全部"},
{"value": "new", "text": "已提交"},
{"value": "agent_processing", "text": "助手处理中"},
{"value": "agent_replied", "text": "助手已回复"},
{"value": "human_pending", "text": "待人工认领"},
{"value": "human_processing", "text": "人工处理中"},
{"value": "staff_replied", "text": "人工已回复"},
{"value": "closed", "text": "已关闭"},
{"value": "cancelled", "text": "已取消"}
]}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "my_ticket_table",
"script": "var tbl = bricks.getWidgetById('my_ticket_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "my_ticket_table",
"options": {
"title": "我的工单",
"width": "100%", "css": "card",
"data_url": "{{entire_url('/ticket/api/my_tickets.dspy')}}",
"data_method": "GET", "page_rows": 20,
"toolbar": {
"tools": [
{"name": "new_ticket", "label": "提交新工单"},
{"name": "view_detail", "label": "查看详情", "selected_row": true}
]
},
"row_options": {
"browserfields": {
"exclouded": ["id", "customer_org_id", "followup_count"],
"alters": {
"category": {"uitype": "code", "data": [
{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"},
{"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]},
"priority": {"uitype": "code", "data": [
{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"},
{"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]},
"status": {"uitype": "code", "data": [
{"value": "new", "text": "已提交"}, {"value": "agent_processing", "text": "助手处理中"},
{"value": "agent_replied", "text": "助手已回复"}, {"value": "human_pending", "text": "待人工认领"},
{"value": "human_processing", "text": "人工处理中"}, {"value": "staff_replied", "text": "人工已回复"},
{"value": "closed", "text": "已关闭"}, {"value": "cancelled", "text": "已取消"}]},
"close_reason": {"uitype": "code", "data": [
{"value": "resolved", "text": "已解决"}, {"value": "cancelled", "text": "客户取消"}]}
}
},
"fields": [
{"name": "ticket_no", "title": "工单号", "type": "str", "length": 32, "cwidth": 12},
{"name": "title", "title": "标题", "type": "str", "length": 200, "cwidth": 24},
{"name": "category", "title": "分类", "type": "str", "length": 32, "cwidth": 8},
{"name": "priority", "title": "优先级", "type": "str", "length": 16, "cwidth": 6},
{"name": "status", "title": "状态", "type": "str", "length": 32, "cwidth": 10},
{"name": "created_at", "title": "提交时间", "type": "timestamp", "cwidth": 14},
{"name": "updated_at", "title": "最后更新", "type": "timestamp", "cwidth": 14}
]
}
},
"binds": [
{
"wid": "self", "event": "new_ticket",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "提交新工单", "width": "46%", "height": "72%",
"archor": "cc", "movable": true,
"dismiss_events": ["cancel", "submited"]},
"options": {"url": "{{entire_url('/ticket/my/new_ticket_form.ui')}}"}
},
{
"wid": "self", "event": "view_detail",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "工单详情", "width": "72%", "height": "82%",
"archor": "cc", "resizable": true},
"options": {"url": "{{entire_url('/ticket/api/ticket_detail_popup.dspy')}}?id=${id}$"}
}
]
}
]
}

View File

@ -0,0 +1,23 @@
{
"widgettype": "Form",
"id": "new_ticket_form",
"options": {
"cols": 1,
"title": "提交工单",
"padding": "16px",
"submit_url": "{{entire_url('/ticket/api/ticket_create.dspy')}}",
"method": "POST",
"fields": [
{"name": "title", "label": "问题标题", "uitype": "str", "required": true, "placeholder": "一句话概括您的问题"},
{"name": "description", "label": "问题描述", "uitype": "text", "required": true, "placeholder": "详细描述问题现象、发生时间、涉及的功能/账户等,便于快速定位"},
{"name": "category", "label": "问题分类", "uitype": "code", "value": "consult",
"data": [{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, {"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]},
{"name": "priority", "label": "优先级", "uitype": "code", "value": "normal",
"data": [{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]}
]
},
"binds": [
{"wid": "self", "event": "submited", "actiontype": "script", "target": "self",
"script": "var d=event.params||{}; await bricks.show_resp_message_or_error(d); if(d.success){ var pw=bricks.getWidgetById('new_ticket_pw',bricks.app); if(pw){pw.destroy();} var tbl=bricks.getWidgetById('my_ticket_table',bricks.app.root); if(tbl){await tbl.render({});} if(window.refreshTodo){window.refreshTodo();} }"}
]
}