# 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` 是需求与设计文档(表结构、接口契约、业务流程、阶段划分),接口字段与流程以其为准;本文件只记录「需要读多个源文件才能拼出」的架构约束与易踩的坑。 ## 常用命令 ```bash # 创建并激活虚拟环境(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_`,`SCAN` 事件 EventKey 直接是 ``,由 `_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.py` import,因此**不要在 `admin.py` 里反向 import `wechat`**(循环依赖)。 ## 数据库 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` 块,否则公网请求会落到占位响应。