From 8bc02590fa0cb0ab0e249cccffb4ab3e2b493531 Mon Sep 17 00:00:00 2001 From: ymq Date: Sat, 15 Aug 2026 20:35:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1=E9=80=9A=E9=81=93?= =?UTF-8?q?=E8=90=BD=E5=9C=B0-=E7=B3=BB=E7=BB=9F=E7=BA=A7=E5=8D=95?= =?UTF-8?q?=E6=9D=A1=E9=85=8D=E7=BD=AE+=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E7=BB=91=E5=AE=9A(=E4=B8=AA=E4=BA=BA=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8F=B7=E6=96=B9=E6=A1=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/wechat_bind_code.json | 22 ++++++++ wwwroot/api/wechat_binding.dspy | 47 +++++++--------- wwwroot/api/wechat_callback.dspy | 93 ++++++++++++++++++++------------ wwwroot/api/wechat_config.dspy | 32 ++++++----- wwwroot/wechat_config/index.ui | 34 ++++++------ 5 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 models/wechat_bind_code.json diff --git a/models/wechat_bind_code.json b/models/wechat_bind_code.json new file mode 100644 index 0000000..27c258c --- /dev/null +++ b/models/wechat_bind_code.json @@ -0,0 +1,22 @@ +{ + "summary": [ + { + "name": "wechat_bind_code", + "title": "微信绑定验证码(验证码→用户,用于关注后发验证码绑定)", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "code", "title": "验证码(6位数字)", "type": "str", "length": 10, "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": "expires_at", "title": "过期时间", "type": "timestamp", "nullable": "no"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_wbc_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_wbc_user", "idxtype": "normal", "idxfields": ["user_id"]} + ] +} diff --git a/wwwroot/api/wechat_binding.dspy b/wwwroot/api/wechat_binding.dspy index 12563d9..9ee74b6 100644 --- a/wwwroot/api/wechat_binding.dspy +++ b/wwwroot/api/wechat_binding.dspy @@ -1,9 +1,11 @@ -# wechat_binding.dspy - 微信用户绑定(openid → 系统用户) +# wechat_binding.dspy - 微信用户绑定(验证码方式:用户在 Web 生成验证码,发给公众号完成绑定) +# action=gen_code: 生成绑定验证码(存 wechat_bind_code,10分钟有效),返回验证码 # action=get: 查当前用户绑定状态 -# action=bind: 绑定自己 openid # action=unbind: 解绑 import json +import random +import time uid = await get_user() if not uid: @@ -12,7 +14,7 @@ if not uid: action = (params_kw or {}).get('action', 'get') dbname = get_module_dbname('pipeline-sdlc') -# 查用户 org_id +# 查用户 org_id(sage 库) org_id = '' try: async with DBPools().sqlorContext('sage') as sor: @@ -22,34 +24,25 @@ try: except Exception: pass -if action == 'get': +if action == 'gen_code': + # 生成 6 位验证码,10 分钟有效 + code = ''.join([str(random.randint(0, 9)) for _ in range(6)]) + expires = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time() + 600)) + async with DBPools().sqlorContext(dbname) as sor: + # 清理该用户旧的未用验证码 + await sor.sqlExe("DELETE FROM wechat_bind_code WHERE user_id=${u}$", {"u": uid}) + await sor.C('wechat_bind_code', { + 'id': getID(), 'code': code, 'user_id': uid, 'org_id': org_id, + 'expires_at': expires, + }) + return json.dumps({"success": True, "code": code}, ensure_ascii=False) + +elif 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) + return json.dumps({"success": True, "bound": bool(bound)}, ensure_ascii=False) elif action == 'unbind': async with DBPools().sqlorContext(dbname) as sor: diff --git a/wwwroot/api/wechat_callback.dspy b/wwwroot/api/wechat_callback.dspy index cae2d3d..9e90926 100644 --- a/wwwroot/api/wechat_callback.dspy +++ b/wwwroot/api/wechat_callback.dspy @@ -1,9 +1,10 @@ -# wechat_callback.dspy - 微信回调(验签 + 消息接收 + openid映射 + gateway路由 + 被动回复) +# wechat_callback.dspy - 微信回调(验签 + 验证码绑定 + 消息接收 + openid映射 + gateway路由 + 被动回复) # GET: 服务器配置验证(验签返回 echostr) -# POST: 接收消息 → openid 映射 user_id → gateway.run_message → 回复 XML +# POST: 接收消息 → 绑定验证码 或 openid映射 user_id → gateway.run_message → 回复 XML import json import hashlib +import re import time import xml.etree.ElementTree as ET @@ -13,17 +14,17 @@ nonce = (params_kw or {}).get('nonce', '') echostr = (params_kw or {}).get('echostr', '') dbname = get_module_dbname('pipeline-sdlc') +SYS_ORG_ID = '0' # 系统级公众号配置 async def _get_channel_config(sor): - """取启用的机构公众号配置(简化:取第一条启用配置;多机构时按 ToUserName 区分)。""" + """取系统级公众号配置(org_id='0' 单条)。""" recs = await sor.sqlExe( - "SELECT org_id, appid, token, encoding_aes_key FROM wechat_channel_config " - "WHERE enabled='1' LIMIT 1", {}) + "SELECT appid, token, encoding_aes_key FROM wechat_channel_config " + "WHERE org_id=${o}$ AND enabled='1' LIMIT 1", {"o": SYS_ORG_ID}) 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', ''), @@ -77,40 +78,66 @@ def _x(node, name): e = node.find(name) return e.text if e is not None else '' -to_user = _x(root, 'ToUserName') # 公众号(机构) +to_user = _x(root, 'ToUserName') # 公众号原始ID from_user = _x(root, 'FromUserName') # openid msg_type = _x(root, 'MsgType') -content = _x(root, 'Content') +content = _x(root, 'Content').strip() 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 "(无回复)" + # ── 验证码绑定:用户发 "绑定 123456" 或 "bd 123456" ── + bind_match = re.match(r'^(?:绑定|bd)\s*(\d{6})\s*$', content, re.IGNORECASE) + if bind_match: + code = bind_match.group(1) + recs = await sor.sqlExe( + "SELECT user_id, org_id FROM wechat_bind_code " + "WHERE code=${c}$ AND expires_at > NOW()", {"c": code}) + if not recs: + reply_text = "验证码无效或已过期,请在系统中重新生成" + else: + b = recs[0] + bind_uid = getattr(b, 'user_id', '') + bind_org = getattr(b, 'org_id', '') + # openid 是否已被其他账号绑定 + ex = await sor.sqlExe( + "SELECT user_id FROM wechat_user_binding WHERE openid=${o}$", {"o": from_user}) + if ex and getattr(ex[0], 'user_id', '') != bind_uid: + reply_text = "该微信已绑定其他账号" + else: + await sor.sqlExe( + "DELETE FROM wechat_user_binding WHERE user_id=${u}$", {"u": bind_uid}) + await sor.C('wechat_user_binding', { + 'id': getID(), 'openid': from_user, 'user_id': bind_uid, + 'org_id': bind_org, + }) + await sor.sqlExe( + "DELETE FROM wechat_bind_code WHERE code=${c}$", {"c": code}) + reply_text = "✅ 绑定成功!现在可以直接发消息给我了" + else: + # ── 正常消息:openid → user_id → gateway ── + user_id = await _resolve_user(sor, from_user) + if not user_id: + reply_text = "您还未绑定账号。请先在系统中「微信通道 → 绑定微信」生成验证码,然后回复「绑定 验证码」" + else: + 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 = ( diff --git a/wwwroot/api/wechat_config.dspy b/wwwroot/api/wechat_config.dspy index 4b20ac7..d114386 100644 --- a/wwwroot/api/wechat_config.dspy +++ b/wwwroot/api/wechat_config.dspy @@ -1,6 +1,6 @@ -# wechat_config.dspy - 微信通道配置(机构级公众号,机构管理员) -# action=get: 返回当前机构公众号配置(appsecret 脱敏) -# action=save: 保存配置(仅机构管理员) +# wechat_config.dspy - 微信通道配置(系统级单条:业主机构公众号,仅系统 admin) +# action=get: 返回系统级公众号配置(appsecret 脱敏) +# action=save: 保存配置(仅系统 admin) import json from appPublic.rc4 import password as _enc, unpassword as _dec @@ -12,14 +12,13 @@ if not uid: action = (params_kw or {}).get('action', 'get') dbname = get_module_dbname('pipeline-sdlc') -# 查用户 org_id + 角色(sage 库) -org_id = '' +# 系统级公众号:全局一条,org_id 固定为 '0'(业主机构) +SYS_ORG_ID = '0' + +# 查用户角色(sage 库),仅 admin 可配置 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}) @@ -31,9 +30,10 @@ except Exception: 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}) + "SELECT * FROM wechat_channel_config WHERE org_id=${o}$", {"o": SYS_ORG_ID}) if not recs: - return json.dumps({"success": True, "config": None}, ensure_ascii=False) + return json.dumps({"success": True, "config": None, "is_admin": is_admin}, + ensure_ascii=False) r = recs[0] return json.dumps({"success": True, "is_admin": is_admin, "config": { "id": getattr(r, 'id', ''), @@ -47,9 +47,7 @@ if action == 'get': 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) + return json.dumps({"success": False, "error": "仅系统管理员可配置微信通道"}, ensure_ascii=False) name = (params_kw or {}).get('name', '') appid = (params_kw or {}).get('appid', '') @@ -64,7 +62,7 @@ elif action == 'save': 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}) + "SELECT appsecret FROM wechat_channel_config WHERE org_id=${o}$", {"o": SYS_ORG_ID}) if recs: appsecret = _dec(getattr(recs[0], 'appsecret', '') or '') @@ -74,16 +72,16 @@ elif action == 'save': 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}) + "SELECT id FROM wechat_channel_config WHERE org_id=${o}$", {"o": SYS_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}) + "e": enabled, "o": SYS_ORG_ID}) else: await sor.C('wechat_channel_config', { - 'id': getID(), 'org_id': org_id, 'name': name, 'appid': appid, + 'id': getID(), 'org_id': SYS_ORG_ID, 'name': name, 'appid': appid, 'appsecret': enc_secret, 'token': token, 'encoding_aes_key': aes_key, 'enabled': enabled, }) diff --git a/wwwroot/wechat_config/index.ui b/wwwroot/wechat_config/index.ui index 248b530..3889cdf 100644 --- a/wwwroot/wechat_config/index.ui +++ b/wwwroot/wechat_config/index.ui @@ -8,7 +8,7 @@ }, { "widgettype": "Text", - "options": {"text": "机构级公众号配置(仅机构管理员)+ 用户微信绑定", "cfontsize": 0.85, "color": "#94a3b8"} + "options": {"text": "系统级公众号配置(业主机构公众号,仅系统管理员)+ 用户微信绑定", "cfontsize": 0.85, "color": "#94a3b8"} }, { "widgettype": "VBox", @@ -17,7 +17,7 @@ "subwidgets": [ { "widgettype": "Title3", - "options": {"text": "公众号配置(机构管理员)"} + "options": {"text": "公众号配置(系统管理员)"} }, { "widgettype": "Form", @@ -62,25 +62,23 @@ "options": {"text": "加载中...", "cfontsize": 0.9, "color": "#64748b"} }, { - "widgettype": "HBox", - "options": {"gap": "8px", "alignItems": "center"}, - "subwidgets": [ + "widgettype": "Button", + "options": {"name": "gen_code_btn", "label": "生成绑定验证码", "css": "primary"}, + "binds": [ { - "widgettype": "Input", - "id": "openid_input", - "options": {"name": "openid_input", "placeholder": "输入您的微信 openid", "cwidth": 24} - }, - { - "widgettype": "Button", - "options": {"name": "bind_btn", "label": "绑定", "css": "primary"}, - "binds": [ - { - "wid": "self", "event": "click", "actiontype": "script", "target": "self", - "script": "var inp=bricks.getWidgetById('openid_input',bricks.app);var oid=(inp&&inp.options.value)||'';if(!oid){bricks.alert('请输入 openid');return;}var body='action=bind&openid='+encodeURIComponent(oid);fetch('/pipeline-sdlc/api/wechat_binding.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){return r.json()}).then(function(j){bricks.alert(j.success?'绑定成功':'绑定失败: '+(j.error||''));if(j.success){var st=bricks.getWidgetById('bind_status',bricks.app);if(st)st.options.text='已绑定 openid: '+oid;}}).catch(function(e){bricks.alert('绑定失败: '+e);});" - } - ] + "wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": "fetch('/pipeline-sdlc/api/wechat_binding.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'action=gen_code'}).then(function(r){return r.json()}).then(function(j){if(!j.success){bricks.alert('生成失败: '+(j.error||''));return;}var code=j.code;var tip=bricks.getWidgetById('bind_tip',bricks.app);if(tip){tip.options.text='验证码:'+code+'(10分钟有效)。请关注公众号后发送:绑定 '+code;}}).catch(function(e){bricks.alert('生成失败: '+e);});" } ] + }, + { + "widgettype": "Text", + "id": "bind_tip", + "options": {"text": "", "cfontsize": 0.9, "color": "#2563eb"} + }, + { + "widgettype": "Text", + "options": {"text": "绑定步骤:1. 点「生成绑定验证码」拿到 6 位码 → 2. 关注公众号「能做事的AI」→ 3. 给公众号发「绑定 验证码」完成绑定", "cfontsize": 0.8, "color": "#94a3b8"} } ] }