feat: 微信通道(机构级公众号配置+用户openid绑定+回调框架)+修复mysql.ddl.sql的xls2ui双重引号bug

This commit is contained in:
ymq 2026-08-15 10:09:57 +08:00
parent c29f676cb3
commit 4a9eb227df
7 changed files with 426 additions and 0 deletions

View File

@ -0,0 +1,25 @@
{
"summary": [
{
"name": "wechat_channel_config",
"title": "微信通道配置(机构级公众号)",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "公众号名称", "type": "str", "length": 200, "nullable": "no"},
{"name": "appid", "title": "公众号AppID", "type": "str", "length": 100, "nullable": "no"},
{"name": "appsecret", "title": "公众号AppSecret(加密)", "type": "str", "length": 500, "nullable": "no"},
{"name": "token", "title": "消息校验Token", "type": "str", "length": 200, "nullable": "no"},
{"name": "encoding_aes_key", "title": "消息加密Key(可选)", "type": "str", "length": 200},
{"name": "enabled", "title": "是否启用", "type": "str", "length": 1, "nullable": "no"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp"}
],
"indexes": [
{"name": "idx_wcc_org", "idxtype": "unique", "idxfields": ["org_id"]}
]
}

View File

@ -0,0 +1,21 @@
{
"summary": [
{
"name": "wechat_user_binding",
"title": "微信用户绑定(openid→系统用户)",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "openid", "title": "微信OpenID", "type": "str", "length": 100, "nullable": "no"},
{"name": "user_id", "title": "系统用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_wub_openid", "idxtype": "unique", "idxfields": ["openid"]},
{"name": "idx_wub_user", "idxtype": "normal", "idxfields": ["user_id"]}
]
}

View File

@ -287,3 +287,47 @@ CREATE INDEX pipeline_agent_questions_idx_paq_tenant ON pipeline_agent_question
CREATE INDEX pipeline_agent_questions_idx_paq_task ON pipeline_agent_questions(task_id);
CREATE INDEX pipeline_agent_questions_idx_paq_status ON pipeline_agent_questions(status);
-- models/wechat_channel_config.json
drop table if exists wechat_channel_config;
CREATE TABLE wechat_channel_config
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`name` VARCHAR(200) NOT NULL comment '公众号名称',
`appid` VARCHAR(100) NOT NULL comment '公众号AppID',
`appsecret` VARCHAR(500) NOT NULL comment '公众号AppSecret(加密)',
`token` VARCHAR(200) NOT NULL comment '消息校验Token',
`encoding_aes_key` VARCHAR(200) comment '消息加密Key(可选)',
`enabled` VARCHAR(1) NOT NULL DEFAULT '1' comment '是否启用(1/0)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '微信通道配置(机构级公众号)'
;
CREATE UNIQUE INDEX wechat_channel_config_idx_wcc_org ON wechat_channel_config(org_id);
-- models/wechat_user_binding.json
drop table if exists wechat_user_binding;
CREATE TABLE wechat_user_binding
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`openid` VARCHAR(100) NOT NULL comment '微信OpenID',
`user_id` VARCHAR(32) NOT NULL comment '系统用户ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '微信用户绑定(openid→系统用户)'
;
CREATE UNIQUE INDEX wechat_user_binding_idx_wub_openid ON wechat_user_binding(openid);
CREATE INDEX wechat_user_binding_idx_wub_user ON wechat_user_binding(user_id);

View File

@ -0,0 +1,57 @@
-- 修复脚本:重建被误删的 sd_deploy_envs + 新建微信通道两张表
-- 注意:原 mysql.ddl.sql 有 xls2ui 生成 bugDEFAULT ''N'' 双重引号),不能整文件执行
CREATE TABLE IF NOT EXISTS sd_deploy_envs (
`id` VARCHAR(32) NOT NULL comment '主键ID',
`project_id` VARCHAR(32) NOT NULL comment '项目ID',
`env_type` VARCHAR(20) NOT NULL comment '环境类型',
`host` VARCHAR(200) NOT NULL comment 'SSH主机地址',
`port` int NOT NULL DEFAULT 22 comment 'SSH端口',
`user` VARCHAR(100) NOT NULL comment 'SSH用户',
`ssh_key_path` VARCHAR(500) comment 'SSH密钥路径',
`sudo_enabled` VARCHAR(1) NOT NULL DEFAULT 'N' comment '免密Sudo',
`deploy_path` VARCHAR(500) NOT NULL comment '部署目录',
`python_path` VARCHAR(500) comment 'Python路径',
`db_host` VARCHAR(200) comment '数据库地址',
`db_port` int DEFAULT 3306 comment '数据库端口',
`db_name` VARCHAR(100) comment '数据库名',
`db_user` VARCHAR(100) comment '数据库用户',
`db_password` VARCHAR(500) comment '数据库密码(加密)',
`status` VARCHAR(20) NOT NULL DEFAULT 'configured' comment '环境状态',
`verified_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '最近验证时间',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间',
primary key(id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci engine=innodb comment '部署环境表';
CREATE INDEX idx_sd_deploy_envs_project ON sd_deploy_envs(project_id);
CREATE INDEX idx_sd_deploy_envs_type ON sd_deploy_envs(env_type);
CREATE UNIQUE INDEX idx_sd_deploy_envs_unique ON sd_deploy_envs(project_id, env_type);
CREATE TABLE IF NOT EXISTS wechat_channel_config (
`id` VARCHAR(32) NOT NULL comment '主键ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`name` VARCHAR(200) NOT NULL comment '公众号名称',
`appid` VARCHAR(100) NOT NULL comment '公众号AppID',
`appsecret` VARCHAR(500) NOT NULL comment '公众号AppSecret(加密)',
`token` VARCHAR(200) NOT NULL comment '消息校验Token',
`encoding_aes_key` VARCHAR(200) comment '消息加密Key(可选)',
`enabled` VARCHAR(1) NOT NULL DEFAULT '1' comment '是否启用(1/0)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间',
primary key(id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci engine=innodb comment '微信通道配置(机构级公众号)';
CREATE UNIQUE INDEX wechat_channel_config_idx_wcc_org ON wechat_channel_config(org_id);
CREATE TABLE IF NOT EXISTS wechat_user_binding (
`id` VARCHAR(32) NOT NULL comment '主键ID',
`openid` VARCHAR(100) NOT NULL comment '微信OpenID',
`user_id` VARCHAR(32) NOT NULL comment '系统用户ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
primary key(id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci engine=innodb comment '微信用户绑定(openid→系统用户)';
CREATE UNIQUE INDEX wechat_user_binding_idx_wub_openid ON wechat_user_binding(openid);
CREATE INDEX wechat_user_binding_idx_wub_user ON wechat_user_binding(user_id);

View File

@ -0,0 +1,61 @@
# wechat_binding.dspy - 微信用户绑定openid → 系统用户)
# action=get: 查当前用户绑定状态
# action=bind: 绑定自己 openid
# action=unbind: 解绑
import json
uid = await get_user()
if not uid:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
action = (params_kw or {}).get('action', 'get')
dbname = get_module_dbname('pipeline-sdlc')
# 查用户 org_id
org_id = ''
try:
async with DBPools().sqlorContext('sage') as sor:
urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
if urecs:
org_id = getattr(urecs[0], 'orgid', '') or ''
except Exception:
pass
if action == 'get':
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT openid FROM wechat_user_binding WHERE user_id=${u}$", {"u": uid})
bound = getattr(recs[0], 'openid', '') if recs else ''
return json.dumps({"success": True, "bound": bool(bound), "openid": bound}, ensure_ascii=False)
elif action == 'bind':
openid = (params_kw or {}).get('openid', '').strip()
if not openid:
return json.dumps({"success": False, "error": "openid 必填"}, ensure_ascii=False)
async with DBPools().sqlorContext(dbname) as sor:
# openid 唯一:检查是否已被其他账号绑定
recs = await sor.sqlExe(
"SELECT user_id FROM wechat_user_binding WHERE openid=${o}$", {"o": openid})
if recs and getattr(recs[0], 'user_id', '') != uid:
return json.dumps({"success": False, "error": "该微信已绑定其他账号"}, ensure_ascii=False)
existing = await sor.sqlExe(
"SELECT id FROM wechat_user_binding WHERE user_id=${u}$", {"u": uid})
if existing:
await sor.sqlExe(
"UPDATE wechat_user_binding SET openid=${o}$ WHERE user_id=${u}$",
{"o": openid, "u": uid})
else:
await sor.C('wechat_user_binding', {
'id': getID(), 'openid': openid, 'user_id': uid, 'org_id': org_id,
})
return json.dumps({"success": True}, ensure_ascii=False)
elif action == 'unbind':
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(
"DELETE FROM wechat_user_binding WHERE user_id=${u}$", {"u": uid})
return json.dumps({"success": True}, ensure_ascii=False)
else:
return json.dumps({"error": "Unknown action: " + str(action)}, ensure_ascii=False)

View File

@ -0,0 +1,125 @@
# wechat_callback.dspy - 微信回调(验签 + 消息接收 + openid映射 + gateway路由 + 被动回复)
# GET: 服务器配置验证(验签返回 echostr
# POST: 接收消息 → openid 映射 user_id → gateway.run_message → 回复 XML
import json
import hashlib
import time
import xml.etree.ElementTree as ET
signature = (params_kw or {}).get('signature', '')
timestamp = (params_kw or {}).get('timestamp', '')
nonce = (params_kw or {}).get('nonce', '')
echostr = (params_kw or {}).get('echostr', '')
dbname = get_module_dbname('pipeline-sdlc')
async def _get_channel_config(sor):
"""取启用的机构公众号配置(简化:取第一条启用配置;多机构时按 ToUserName 区分)。"""
recs = await sor.sqlExe(
"SELECT org_id, appid, token, encoding_aes_key FROM wechat_channel_config "
"WHERE enabled='1' LIMIT 1", {})
if recs:
r = recs[0]
return {
'org_id': getattr(r, 'org_id', ''),
'appid': getattr(r, 'appid', ''),
'token': getattr(r, 'token', ''),
'encoding_aes_key': getattr(r, 'encoding_aes_key', ''),
}
return None
async def _resolve_user(sor, openid):
"""openid → user_id未绑定返回空。"""
recs = await sor.sqlExe(
"SELECT user_id FROM wechat_user_binding WHERE openid=${o}$", {"o": openid})
return getattr(recs[0], 'user_id', '') if recs else ''
# ── GET 验签 ──
if echostr:
async with DBPools().sqlorContext(dbname) as sor:
cfg = await _get_channel_config(sor)
if not cfg:
return "未配置微信通道"
tmp = sorted([cfg['token'], timestamp, nonce])
sha1 = hashlib.sha1(''.join(tmp).encode('utf-8')).hexdigest()
if sha1 == signature:
return echostr
return "验签失败"
# ── POST 消息处理 ──
# 微信消息 XML 在 request body。Sage DSPY 环境:优先取 params_kw 里的 xml否则取 request body。
xml_body = (params_kw or {}).get('xml', '')
if not xml_body:
try:
xml_body = await request.text() if hasattr(request, 'text') else ''
except Exception:
xml_body = ''
if not xml_body:
try:
xml_body = (await request.read()).decode('utf-8') if hasattr(request, 'read') else ''
except Exception:
xml_body = ''
if not xml_body:
return ""
try:
root = ET.fromstring(xml_body)
except Exception:
return ""
def _x(node, name):
e = node.find(name)
return e.text if e is not None else ''
to_user = _x(root, 'ToUserName') # 公众号(机构)
from_user = _x(root, 'FromUserName') # openid
msg_type = _x(root, 'MsgType')
content = _x(root, 'Content')
if msg_type != 'text' or not from_user:
# 非文本消息,回复统一提示
reply_text = "暂只支持文本消息"
else:
async with DBPools().sqlorContext(dbname) as sor:
cfg = await _get_channel_config(sor)
user_id = await _resolve_user(sor, from_user)
if not user_id:
reply_text = "未绑定账号,请先登录系统在「微信绑定」中绑定您的微信"
else:
# 走 gateway 统一入口(微信通道)
from pipeline_service.gateway import get_gateway
gw = get_gateway()
parts = []
try:
async for chunk in gw.run_message("wechat", user_id, content):
d = json.loads(chunk)
t = d.get('type', '')
if t == 'reply':
parts.append(d.get('message', ''))
elif t == 'ask_user':
parts.append("❓ " + d.get('message', ''))
elif t == 'error':
parts.append("⚠️ " + d.get('message', ''))
# tool_call/tool_result 不推送给微信(避免刷屏)
except Exception as e:
parts.append(f"ERROR: {str(e)[:200]}")
reply_text = "\n".join(parts) if parts else "(无回复)"
# ── 被动回复 XML ──
reply_xml = (
"<xml>"
f"<ToUserName><![CDATA[{from_user}]]></ToUserName>"
f"<FromUserName><![CDATA[{to_user}]]></FromUserName>"
f"<CreateTime>{int(time.time())}</CreateTime>"
"<MsgType><![CDATA[text]]></MsgType>"
f"<Content><![CDATA[{reply_text}]]></Content>"
"</xml>"
)
return reply_xml

View File

@ -0,0 +1,93 @@
# wechat_config.dspy - 微信通道配置(机构级公众号,机构管理员)
# action=get: 返回当前机构公众号配置appsecret 脱敏)
# action=save: 保存配置(仅机构管理员)
import json
from appPublic.rc4 import password as _enc, unpassword as _dec
uid = await get_user()
if not uid:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
action = (params_kw or {}).get('action', 'get')
dbname = get_module_dbname('pipeline-sdlc')
# 查用户 org_id + 角色sage 库)
org_id = ''
is_admin = False
try:
async with DBPools().sqlorContext('sage') as sor:
urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
if urecs:
org_id = getattr(urecs[0], 'orgid', '') or ''
rrecs = await sor.sqlExe(
"SELECT r.name FROM userrole ur JOIN role r ON ur.roleid=r.id WHERE ur.userid=${u}$",
{"u": uid})
roles = [getattr(r, 'name', '') for r in (rrecs or [])]
is_admin = 'admin' in roles
except Exception:
pass
if action == 'get':
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM wechat_channel_config WHERE org_id=${o}$", {"o": org_id})
if not recs:
return json.dumps({"success": True, "config": None}, ensure_ascii=False)
r = recs[0]
return json.dumps({"success": True, "is_admin": is_admin, "config": {
"id": getattr(r, 'id', ''),
"name": getattr(r, 'name', ''),
"appid": getattr(r, 'appid', ''),
"appsecret": "***", # 脱敏,不回传明文
"token": getattr(r, 'token', ''),
"encoding_aes_key": getattr(r, 'encoding_aes_key', ''),
"enabled": getattr(r, 'enabled', '1'),
}}, ensure_ascii=False)
elif action == 'save':
if not is_admin:
return json.dumps({"success": False, "error": "仅机构管理员可配置微信通道"}, ensure_ascii=False)
if not org_id:
return json.dumps({"success": False, "error": "用户未关联机构"}, ensure_ascii=False)
name = (params_kw or {}).get('name', '')
appid = (params_kw or {}).get('appid', '')
appsecret = (params_kw or {}).get('appsecret', '')
token = (params_kw or {}).get('token', '')
aes_key = (params_kw or {}).get('encoding_aes_key', '')
enabled = (params_kw or {}).get('enabled', '1')
if not appid or not token:
return json.dumps({"success": False, "error": "appid/token 必填"}, ensure_ascii=False)
# appsecret 为空时表示不修改(保留原值)
if not appsecret:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT appsecret FROM wechat_channel_config WHERE org_id=${o}$", {"o": org_id})
if recs:
appsecret = _dec(getattr(recs[0], 'appsecret', '') or '')
if not appsecret:
return json.dumps({"success": False, "error": "appsecret 必填"}, ensure_ascii=False)
enc_secret = _enc(appsecret)
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id FROM wechat_channel_config WHERE org_id=${o}$", {"o": org_id})
if recs:
await sor.sqlExe(
"UPDATE wechat_channel_config SET name=${n}$, appid=${a}$, appsecret=${s}$, "
"token=${t}$, encoding_aes_key=${k}$, enabled=${e}$ WHERE org_id=${o}$",
{"n": name, "a": appid, "s": enc_secret, "t": token, "k": aes_key,
"e": enabled, "o": org_id})
else:
await sor.C('wechat_channel_config', {
'id': getID(), 'org_id': org_id, 'name': name, 'appid': appid,
'appsecret': enc_secret, 'token': token, 'encoding_aes_key': aes_key,
'enabled': enabled,
})
return json.dumps({"success": True}, ensure_ascii=False)
else:
return json.dumps({"error": "Unknown action: " + str(action)}, ensure_ascii=False)