9.7 KiB
9.7 KiB
CODEBUDDY.md
This file provides guidance to CodeBuddy Code when working with code in this repository.
项目概述
微信公众号扫码授权服务,基于 FastAPI,为 Windows MFC 桌面程序提供微信扫码授权与使用扣减。已实现微信服务器验证与消息/事件接收(/wechat)、扫码授权(/auth)、使用扣减(/usage)、只读管理后台(/admin)以及首次关注赠送 7 天免费授权。MySQL 已接入(aiomysql 连接池,5 张表);Redis 仅在配置层声明,尚未使用。
REQUIREMENTS.md 是需求与设计文档(表结构、接口契约、业务流程、阶段划分),接口字段与流程以其为准;本文件只记录「需要读多个源文件才能拼出」的架构约束与易踩的坑。
常用命令
# 创建并激活虚拟环境(Windows)
python -m venv venv
source venv/Scripts/activate # bash;PowerShell 用 venv\Scripts\Activate.ps1
# 安装依赖
pip install -r requirements.txt
# 初始化本地配置(然后填入真实值)
cp .env.example .env
# 建库建表(MySQL 8,创建 wechat_api 库与 5 张表)
mysql -u root -p < sql/schema.sql
# 本地开发(uvicorn reload,监听 127.0.0.1:8000)
python run_local.py
# 等价于:
uvicorn wechat:app --reload --host 127.0.0.1 --port 8000
# 服务器部署(不 reload,单 worker,监听 127.0.0.1:8000,由 Nginx 反代)
python run_server.py
当前仓库没有测试框架、lint 或构建配置;如需运行单个测试,需先引入 pytest 等工具。
架构
config.py # 从 .env 读取配置(dotenv),模块级常量
db.py # aiomysql 连接池:init_pool / close_pool / acquire(autocommit)
wechat_api.py # 微信开放接口:access_token 内存缓存 + 临时二维码创建
wechat.py # FastAPI app 本体:lifespan + /wechat 路由 + 签名校验 + XML 解析/构造
auth.py # /auth/* 路由 + 扫码授权业务逻辑(含 pending 激活)
usage.py # /usage/consume 路由:会话校验与使用扣减
admin.py # /admin 只读管理后台:Basic Auth + 服务端渲染 HTML
run_local.py # 开发启动入口(reload=True)
run_server.py # 生产启动入口(reload=False, workers=1)
sql/schema.sql # 建表脚本(5 张表,含索引与外键)
REQUIREMENTS.md # 需求与设计文档(表结构 / 接口契约 / 业务流程 / 阶段划分)
- 配置:所有敏感值经
config.py从.env读取,.env已被.gitignore排除。.env.example是字段模板。新增配置项需同时更新这两处。 - 应用入口:两个启动脚本均以
"wechat:app"字符串形式加载wechat.py中的app,因此模块名/对象名不可随意重命名。lifespan在启动时初始化 MySQL 连接池——MySQL 不可达或库表不存在时应用会直接启动失败。 - 数据访问:
db.acquire()借出的连接是autocommit=True,单条写语句自动提交;需要多条语句原子性时必须显式await conn.begin()/commit()(异常rollback())。auth.py/usage.py的事务都是这个模式。 - 路由挂载:
wechat.py通过include_router挂载auth.router、usage.router与admin.router。新增 MFC 侧接口应新建独立模块的 router,而不是塞进wechat.py。 - 微信交互协议:
- 所有请求先经
verify_signature()(token+timestamp+nonce 字典序拼接后 SHA1 比对)校验,失败返回 403;WECHAT_TOKEN为空时校验恒失败。 - GET 校验通过后原样返回
echostr。 - POST 解析微信推送的 XML(
MsgType/FromUserName/Event等),通过_reply_text()构造文本回复 XML 返回。新增消息类型处理应在wechat_message()的事件/消息分支中扩展(现处理subscribe/SCAN/unsubscribe/CLICK与文本 echo)。 subscribe事件 EventKey 形如qrscene_<scene_str>,SCAN事件 EventKey 直接是<scene_str>,由_parse_scene_key()统一提取。
- 所有请求先经
- 微信 access_token:
wechat_api.get_access_token()在进程内内存缓存(有效期 7200 秒,提前 300 秒失效),命中 40001/42001 时清空缓存下次重取。部署按单 worker 设计(run_server.py也是workers=1),多进程会各存一份缓存。 - 注意:
_reply_text()中 ToUserName/FromUserName 是反置的(回复时收发方互换),这是微信协议要求。
授权与扣减状态机
- 互斥原则:同一用户同一时刻最多一条
status = 'active'的授权。 - 惰性激活:
auth.activate_pending_authorization()先把已失效的 active 标记为 expired/exhausted,再无 active 时按 FIFO 激活一条 pending。调用点有三处:/auth/status、扫码事件handle_scan()、/usage/consume。必须在get_active_authorization()之前调用(后者带惰性置失效的副作用)。时间授权激活时按原时长从当前时刻重新锚定。 - 并发串行化:
handle_scan()对 scene 行加SELECT ... FOR UPDATE(同一 scene 只处理一次);activate_pending_authorization()对 users 行加FOR UPDATE(串行化同一用户的并发激活)。二者都要求调用方已开启事务。 - 免费授权幂等:靠
UPDATE users SET has_claimed_free = 1 WHERE id = ? AND has_claimed_free = 0的rowcount == 1作闸门,只有把 0 改 1 的那一次才真正插入授权;已有 active 授权时,新发的免费授权以pending保存。 - 积分扣减:条件
UPDATE ... WHERE id = ? AND status = 'active' AND remaining_points > 0+rowcount判定,禁止应用层先读后写。status的赋值必须写在remaining_points自减之前——MySQL 的 SET 从左到右求值,否则IF会读到已减 1 的值,判空差 1。同理,激活 pending 时end_at必须写在start_at之前。 - session_token:随机生成、存
sessions表、有效期 24 小时、绑定签发时的device_id。/usage/consume严格校验 token 存在、未过期且device_id一致,任一不符返回invalid_token。 - usage_logs:积分授权每次调用都写(计费凭证);时间授权按
(user_id, device_id)在USAGE_LOG_THROTTLE_SECONDS(默认 60 秒)窗口内节流。 - 失败语义:
/usage/consume一律返回 HTTP 200,用{"ok": false, "reason": "expired | exhausted | invalid_token"}表达失败;无 active 授权时_reason_without_active()按最近一条失效授权的类型决定 reason 是exhausted还是expired。
接口错误码(MFC 侧)
POST /auth/create_scene:微信接口不可用时返回 502。GET /auth/status:scene 不存在返回 404;状态取值pending | authorized | expired | need_purchase,其中need_purchase= 免费已领过且当前无有效授权(引导充值,阶段 3)。POST /usage/consume:失败仍为 HTTP 200,靠 body 里的ok=false。
管理后台(/admin)
只读单页,用于查看用户与授权现状:概览统计、用户+当前授权、最近使用记录、会话令牌、扫码场景。
- 鉴权:HTTP Basic Auth,凭据取自
.env的ADMIN_USER/ADMIN_PASSWORD,用secrets.compare_digest对 UTF-8 字节做定时安全比较(对含非 ASCII 的 str 直接比会抛TypeError)。 ADMIN_PASSWORD为空时/admin返回 503 而非放行——不要把它当成可选项,否则等于把全库用户数据公开。- realm 必须 ASCII:Starlette 按 latin-1 编码响应头,中文 realm 会直接抛异常(当前为
CadOcr Admin)。 - 只提供 GET,没有任何写操作(改授权/加积分/封号一律不做)。
- 不引模板引擎:HTML 由 f-string 拼装,所有入库字段经
html.escape();CSS 内联,不依赖任何 CDN(服务器出网不可靠)。会话令牌在页面上只显示首尾(_token()),不给全量。 - 页脚会显示数据库名;页头显示数据生成时间。不做自动刷新。
admin.py被wechat.pyimport,因此不要在admin.py里反向 importwechat(循环依赖)。
数据库
5 张表(定义见 sql/schema.sql):users、authorizations(授权,type 分 time/points)、auth_scenes(扫码场景,300 秒一次性)、usage_logs(使用日志)、sessions(会话令牌)。
auth_scenes被授权后 status 不再回到 pending,重复扫码只处理第一次。- 外键:删除 user 会级联删除其 authorizations / usage_logs / sessions;
auth_scenes.user_id、sessions.scene_id、usage_logs.authorization_id为ON DELETE SET NULL。 - 场景过期、授权到期/耗尽的惰性标记在读取时完成,没有后台定时任务。
REQUIREMENTS.md里的orders表属于阶段 3,sql/schema.sql尚未包含。
AI 记忆备份(.ai-memory/)
.ai-memory/ 是 CodeBuddy 持久记忆的仓库副本——记忆本体存在本机
~/.codebuddy/projects/<工作目录名>/memory/,不进版本控制。目录说明见 .ai-memory/README.md。
- 改完记忆必须同步:把记忆目录的文件复制到
.ai-memory/并提交,否则换机器会丢。 - 该目录会进 Git,因此绝不可写入密码、token、私钥——那些一律留在服务器
.env。 .gitattributes强制.ai-memory/*.md使用 LF,便于逐字节比对;改动时保持 LF。- 新环境恢复:clone 后让助手「从
.ai-memory/导入记忆」。
约定
- 代码注释与文档字符串使用中文。
- 生产环境仅监听 127.0.0.1,对外暴露依赖 Nginx 反向代理。新增对外路由(如
/auth/、/usage/)时,必须同步在服务器 Nginx 配置里加对应的location块,否则公网请求会落到占位响应。