feat: 微信通道落地-系统级单条配置+验证码绑定(个人订阅号方案)

This commit is contained in:
ymq 2026-08-15 20:35:47 +08:00
parent 74142a8a05
commit 8bc02590fa
5 changed files with 133 additions and 95 deletions

View File

@ -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"]}
]
}

View File

@ -1,9 +1,11 @@
# wechat_binding.dspy - 微信用户绑定openid → 系统用户)
# wechat_binding.dspy - 微信用户绑定(验证码方式:用户在 Web 生成验证码,发给公众号完成绑定)
# action=gen_code: 生成绑定验证码(存 wechat_bind_code10分钟有效返回验证码
# 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_idsage 库)
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:

View File

@ -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 = (

View File

@ -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,
})

View File

@ -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"}
}
]
}