diff --git a/models/wechat_channel_config.json b/models/wechat_channel_config.json new file mode 100644 index 0000000..135d6e5 --- /dev/null +++ b/models/wechat_channel_config.json @@ -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"]} + ] +} diff --git a/models/wechat_user_binding.json b/models/wechat_user_binding.json new file mode 100644 index 0000000..8aa41da --- /dev/null +++ b/models/wechat_user_binding.json @@ -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"]} + ] +} diff --git a/mysql.ddl.sql b/mysql.ddl.sql index 43ee845..4373e23 100644 --- a/mysql.ddl.sql +++ b/mysql.ddl.sql @@ -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); + diff --git a/scripts/fix_wechat_ddl.sql b/scripts/fix_wechat_ddl.sql new file mode 100644 index 0000000..573ea48 --- /dev/null +++ b/scripts/fix_wechat_ddl.sql @@ -0,0 +1,57 @@ +-- 修复脚本:重建被误删的 sd_deploy_envs + 新建微信通道两张表 +-- 注意:原 mysql.ddl.sql 有 xls2ui 生成 bug(DEFAULT ''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); diff --git a/wwwroot/api/wechat_binding.dspy b/wwwroot/api/wechat_binding.dspy new file mode 100644 index 0000000..12563d9 --- /dev/null +++ b/wwwroot/api/wechat_binding.dspy @@ -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) diff --git a/wwwroot/api/wechat_callback.dspy b/wwwroot/api/wechat_callback.dspy new file mode 100644 index 0000000..cae2d3d --- /dev/null +++ b/wwwroot/api/wechat_callback.dspy @@ -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 = ( + "" + f"" + f"" + f"{int(time.time())}" + "" + f"" + "" +) +return reply_xml diff --git a/wwwroot/api/wechat_config.dspy b/wwwroot/api/wechat_config.dspy new file mode 100644 index 0000000..4b20ac7 --- /dev/null +++ b/wwwroot/api/wechat_config.dspy @@ -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)