feat: 微信通道落地-系统级单条配置+验证码绑定(个人订阅号方案)
This commit is contained in:
parent
74142a8a05
commit
8bc02590fa
22
models/wechat_bind_code.json
Normal file
22
models/wechat_bind_code.json
Normal 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"]}
|
||||
]
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user