Compare commits

...
10 Commits
Author SHA1 Message Date
gjm 210b92b407 chore: 记忆记录微信通道定位为备用
用户明确:微信端拉不起调试环境,只作备用;主力仍是电脑前用 CLI。
核心价值在记忆数据——换机器也能靠 .ai-memory 恢复。
2026-09-27 20:56:41 +08:00
gjm 76fb5868dc chore: 记忆记录微信绑定的正确姿势
用户实测:/remote-control 要选 Add WeChat Account(别选企业微信那条),
终端二维码扫不出时直接在手机浏览器打开底下的链接即可完成绑定。
2026-09-27 20:54:50 +08:00
gjm bc201210a5 chore: 记忆记录微信远程控制已打通
用户 2026-09-27 用 /remote-control wechat 绑定成功,补充:回复必须走 WechatReply、
绑定不持久化重启需重连、本机窗口须保持运行。
2026-09-27 20:51:31 +08:00
gjm 553e416303 chore: 记忆更新——模型文件已加 svn:ignore(SVN r41)
之前记的「svn status 会长期列出 8 个 ? 模型文件」已过期:r41 已给 CadOcr/onnx 与
CadOcr/src/models 设置 svn:ignore,工作区恢复干净。
2026-09-27 20:37:06 +08:00
gjm 719f88105c chore: 记忆记录 CAD 插件改动已提交 SVN r40
- project_cad_wxauth_integration.md 补充 SVN r40 提交详情,并记下 .eonnx 模型有意不入库(clone 后无法直接构建 CadOcr)
- project_progress.md 同步该状态
2026-09-27 20:35:29 +08:00
gjm 877938c82c chore: 记忆新增「改完记忆自动同步」约定
用户 2026-09-27 明确要求:以后改完记忆自动同步到仓库,不需要提醒。
- 新增 feedback_memory_sync.md 记录该要求与执行细节
- MEMORY.md 索引补一行
2026-09-27 20:30:43 +08:00
gjm ad4854fdb3 chore: 固定 .ai-memory 用 LF 换行符
core.autocrlf=true 会让 clone 时把记忆文件转成 CRLF,导致「记忆目录 <-> 仓库」
逐字节比对始终有噪音。加 .gitattributes 指定 .ai-memory/*.md 使用 eol=lf。
2026-09-27 20:28:29 +08:00
gjm 6c5770b754 chore: 新增 .ai-memory 记忆备份,换机器可恢复
CodeBuddy 的记忆原本只存在本机用户目录(不进版本控制),换机器即丢失。
把 5 个记忆文件备份进仓库,clone 后让助手「从 .ai-memory/ 导入记忆」即可恢复。

- README 说明用途、导入方法与维护约定
- CODEBUDDY.md 补充同步约定(改完记忆须同步本目录)
- 内容仅含基础设施信息(IP/域名/用户名),无任何密码或密钥
2026-09-27 20:27:45 +08:00
gjm f167550957 feat: 新增 /admin 只读授权管理后台
- admin.py:HTTP Basic Auth + 服务端渲染单页,概览统计 / 用户与当前授权 /
  最近使用记录 / 会话令牌 / 扫码场景五个区块
- ADMIN_PASSWORD 为空时返回 503 而非放行,避免漏配密码导致全库数据公开
- 不引模板引擎与 CDN,所有入库文本经 html.escape;令牌只显示首尾便于比对
- config.py / .env.example 新增 ADMIN_USER / ADMIN_PASSWORD
- wechat.py 挂载 admin.router;CODEBUDDY.md 补充文档
2026-09-27 20:03:59 +08:00
gjm 3a8adda365 docs: 更新 CODEBUDDY.md 架构章节至阶段 2 现状
- 项目概述补充 /auth、/usage 与 MySQL 已接入
- 架构补齐 db.py / wechat_api.py / auth.py / usage.py / sql/schema.sql
- 新增「授权与扣减状态机」与「数据库」两节:互斥原则、惰性激活、
  免费授权幂等、积分扣减的 SET 求值顺序、session 校验、日志节流
- 约定补充:新增对外路由需同步加 Nginx location 块
2026-09-27 15:18:43 +08:00
13 changed files with 827 additions and 8 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- [项目当前进度与待办](project_progress.md) — 三阶段状态、ICP 备案阻塞、出包前必须去掉 IP 覆盖等;**新对话先读这个**
- [生产服务器部署环境](project_server_deploy.md) — ethereal-realm.top 的路径/服务/MySQL/nginx 实况,自建 Git 代码服务器拓扑,及 ICP 拦截、pypi 不可达、MySQL 非 Docker、nginx 双 server 块等坑
- [CAD 插件微信授权集成](project_cad_wxauth_integration.md) — d:/Ethereal-Realm 的 CadOcr+cadAgent 接入进度、两端分工、先编 CadOcr 的顺序约束、本机只能编 R230+
- [用户的命令行习惯与协作方式](user_shell_workflow.md) — 用 PuTTY,长命令会被折行截断,sudo 操作要写成脚本;已打通微信远程控制(回复走 WechatReply)
- [改完记忆自动同步](feedback_memory_sync.md) — 每次改完记忆立刻同步到 .ai-memory/ 并提交,不用用户提醒
+47
View File
@@ -0,0 +1,47 @@
# .ai-memory —— AI 助手记忆备份
本目录是 CodeBuddy Code 在本项目的**持久记忆备份**。
## 它是什么
CodeBuddy 的记忆原本存放在**本机用户目录**,不进版本控制:
```
C:\Users\<用户名>\.codebuddy\projects\d-wx-scan-authorize\memory\
```
那里存的是「代码和 git 历史里看不出来」的东西:生产服务器实况、踩过的坑、
协作习惯、当前进度与待办。换一台机器这些就没了,所以在仓库里留一份副本。
## 怎么用
**换机器 / 新环境恢复:**
1. `git clone` 本仓库
2. 对 CodeBuddy 说:「从 `.ai-memory/` 导入记忆」
3. 助手会把本目录的文件写回本机记忆目录
> 注意:记忆目录名由**工作目录路径**派生(`d:\wx-scan-authorize` →
> `d-wx-scan-authorize`)。把仓库克隆到 `d:\wx-scan-authorize` 可保持目录名
> 一致;放在别的路径也能用,导入时告诉助手实际路径即可。
**日常维护:**
改完记忆后,把本机记忆目录的文件同步回本目录并提交。
CodeBuddy 每次修改记忆时都会顺手同步,不需要你提醒。
## 里面有什么
| 文件 | 内容 |
|---|---|
| `MEMORY.md` | 索引,助手每次对话自动加载 |
| `project_progress.md` | 三阶段进度、阻塞项、待办(新对话先读这个) |
| `project_server_deploy.md` | 生产服务器实况与反直觉的坑 |
| `project_cad_wxauth_integration.md` | CAD 插件集成进度与构建约束 |
| `user_shell_workflow.md` | 协作习惯(PuTTY 长命令折行、sudo 走脚本等) |
## 重要:不要在这里放密钥
本目录**会进 Git**。所有密码、token、私钥一律留在服务器的 `.env` 里,
不要写进这些记忆文件。当前文件里只有基础设施信息(IP、域名、用户名),
没有任何凭据。
+20
View File
@@ -0,0 +1,20 @@
---
name: 改完记忆自动同步到仓库
description: 每次修改记忆文件后必须立刻同步到项目的 .ai-memory/ 并提交推送,不需要用户提醒
type: feedback
---
每次修改记忆文件后,**立刻**把记忆目录的文件同步到项目的 `.ai-memory/` 并提交推送,
**不需要用户提醒**。
**Why:** CodeBuddy 的记忆存在本机用户目录
(`~/.codebuddy/projects/<工作目录名>/memory/`),不进版本控制。用户 2026-09-27 明确
要求「以后改完记忆自动同步」——他换机器时要靠仓库里的这份备份恢复全部上下文,
漏同步就等于丢记忆。用户专门问过「换一台机器你还能正常工作吗」,可见在意这件事。
**How to apply:** 任何一次对记忆文件的增删改(新建、修正、删除都算)之后,
**在同一个回复里**走完:复制到 `.ai-memory/` → `git add` → `git commit` → `git push`。
- 同步后做一次逐字节比对(`cmp`)确认,顺手汇报。
- `.ai-memory/` 会进 Git,**绝不可写入密码、token、私钥**——提交前扫一遍。
- 若本次对话没有改动记忆,**不要制造空提交**,如实说「已一致」。
- 相关背景见 `CODEBUDDY.md` 的「AI 记忆备份(.ai-memory/)」章节。
@@ -0,0 +1,60 @@
---
name: CAD 插件微信授权集成
description: d:/Ethereal-Realm 里 CadOcr+cadAgent 接入微信扫码授权的进度、两端分工与构建顺序约束,以及本机只能编 R230/R243+ 工具集的事实
type: project
---
把 `d:/Ethereal-Realm`(SVN 工作区)里的 CAD 插件从「本地时间毒药」授权换成
服务端(本项目 d:/wx-scan-authorize)微信扫码授权。2026-09-27 完成编码 +
客户端侧端到端验证。
- 公众号后台「服务器配置」URL 已改到 Cloudflare 快速隧道,微信真实
`subscribe`/`SCAN` 推送已打通(见 [生产服务器部署环境](project_server_deploy.md))。
- **仍需用户本人做**:在 AutoCAD 里加载 `cadAgent.arx`,人工验证二维码对话框
(我这边没有 AutoCAD)。用户 2026-09-27 表示暂不做。
- `test_wxauth.exe` 已从控制台程序改造成 **Win32 对话框**:二维码显示区 +
本地授权状态区 + 「清空令牌」「模拟新机器」两个按钮。为此 DLL 新增导出
序号 **44/45**(`ocr_wxAuthGetLocalState` / `ocr_wxAuthClearLocalState`)。
**已提交 SVN r40(2026-09-27 20:34)**
`d:/Ethereal-Realm` 用 SVN 管理(`svn://47.93.46.28/Ethereal-Realm`)。
微信授权相关改动共 22 个路径已提交:CadOcr 的 `wxAuth.cpp/.h`、`wx_auth_export.h`、
`securityEngine.h`、`cadOCR.def`、`impl_ocr.cpp`、`test/`,cadAgent 的 `WxAuthDlg.cpp/.h`
及接入改动,以及 `CODEBUDDY.md`、`CadOcr/mfc.md`。
- **`.eonnx` 加密模型未纳入版本控制**(用户 2026-09-27 明确决定):`CadOcr/src/models/`
下约 140 MB、`CadOcr/onnx/` 下同一份的另一拷贝。`src/CadOcr.rc` 通过
`IDR_ONNX_*` 引用它们(RC 把模型嵌进 DLL),**所以 clone 后无法直接构建 CadOcr**,
需另行准备模型文件。
- 已加 `svn:ignore` 让 `svn status` 保持干净(**r41**):`CadOcr/onnx` 在原有忽略
`.onnx` 的基础上追加 `*.eonnx`、`*.etxt`;`CadOcr/src/models` 新增同样两条。
所以那 8 个模型文件现在**不会**出现在 `svn status` 里,也**不该被 add**。
- `CadOcr/test/test_wxauth.rc` 是 UTF-16LE,SVN 自动标记为
`application/octet-stream`(二进制),与现有 `CadOcr/src/CadOcr.rc` 处理一致,属正常。
**分工**(用户拍板)
- `CadOcr`(DLL,无 MFC):全部网络 + 注册表 + 授权状态机(`src/wxAuth.cpp`)。
- `cadAgent`(ARX,MFC):只做 UI(`WxAuthDlg.cpp`)。
- 新增导出序号 **40~43**(`ocr_wxAuthBegin/Poll/Reset/FreeBuffer`),原有 2/5/13/25
一律不动。宿主侧 `ocr_check` 仍导出在序号 20,握手保留、时间毒药删除。
**关键约束**
- **必须先编 CadOcr 再编 cadAgent**:`CadOcr/include/*.h` 靠 PostBuildCopy 同步到
`CADProject/dep_inc/cadOcr/`,顺序反了会用到旧头文件。
- 服务端基址是编译期常量 `CADOCR_WX_BASE_URL`(默认域名),联调时用
`cmake --preset msvc-ocr -DCADOCR_WX_BASE_URL=http://39.107.55.179` 覆盖。
刻意不做运行时覆盖,避免被指向假服务端绕过授权。
- 本机 VS18 只装了 MSVC 14.16/14.29/14.42/14.44/14.51,**没有 v100(VS2010)、
v140(VS2015)**,所以 `R190`/`R220` 这两个 preset 编不出来(`CADProject/out/R190`
里连 vcxproj 都没生成)。实际能编的是 **R230/R243/R250/R260**;`build.bat`
里写的 R220 会失败。
- CadOcr 的 `test/test_wxauth.exe` 是独立验证程序,不装 AutoCAD 就能跑通整条链路。
它靠 `#pragma comment(linker, "/EXPORT:ocr_check,@20,NONAME")` 导出握手函数
—— CMake 只对库目标识别 `.def`,对可执行目标会当普通文件忽略。
**Why:** 这些跨了两个工作区的耦合约束(构建顺序、共享头同步、导出序号、
工具集可用性)从任一端的代码里都看不出来,踩了会很难查。
**How to apply:** 改 CadOcr 的公共头后务必先编 CadOcr;给用户的构建命令别用
R190/R220;讨论授权链路时记得 UI 在 cadAgent、网络在 CadOcr。
+45
View File
@@ -0,0 +1,45 @@
---
name: 项目当前进度与待办
description: wx-scan-authorize 三阶段状态快照、当前阻塞项与下一步,用于跨对话续接——新对话应先读本文件
type: project
---
`d:/wx-scan-authorize`(微信公众号扫码授权服务)截至 **2026-09-27 晚** 的状态。
**三阶段进度**
| 阶段 | 内容 | 状态 |
|---|---|---|
| 1 | 扫码授权(`/wechat` 事件 + `/auth/*` + 首次关注送 7 天) | 已上线,微信真实推送已打通 |
| 2 | 使用扣减(`/usage/consume` + session_token + 节流记账) | 已上线 |
| 3 | 充值(微信公众号支付) | **未开始** |
| 附加 | 只读管理后台 `/admin`(计划外新增) | 2026-09-27 已上线 |
代码状态:本地与服务器均在 `f167550`,工作区干净,远端是自建 Git 服务器
(见 [生产服务器部署环境](project_server_deploy.md))。
客户端在 `d:/Ethereal-Realm`:CadOcr + cadAgent 已完成编码与客户端侧端到端验证,
`test_wxauth.exe` 已从控制台程序改造成 Win32 对话框,**相关改动已提交 SVN r40**,
模型文件按用户决定未入库并已加 `svn:ignore`(r41)。详见
[CAD 插件微信授权集成](project_cad_wxauth_integration.md)。
**阻塞项与待办**
1. **ICP 备案进行中**(用户 2026-09-27 已提交,等待通过)——当前最大的外部变量:
- 通过前:公众号后台「服务器配置」URL 指向 Cloudflare 快速隧道
(URL 每次重启会变,捞取命令见 server_deploy 记忆)
- 通过后:切回 `http://ethereal-realm.top/wechat`,并撤掉隧道
2. **阶段 3 充值暂缓**。用户原话:「阶段3现放着吧,因为微信公众号想要收费的话,
需要营业执照,现在域名的备案也没有做好。」→ 等营业执照 + 备案。
3. **正式出包前必须去掉编译覆盖** `-DCADOCR_WX_BASE_URL=http://39.107.55.179`
——当前本地 DLL 是 IP 联调版,把服务端地址写死成了 IP。
4. **AutoCAD 里人工验证 `cadAgent.arx`** ——我这边没有 AutoCAD,只能用户做。
用户 2026-09-27 明确表示暂不做。
5. **IP 直连暴露 `/docs` 与 `/openapi.json`** ——用户选择暂不处理(详见
server_deploy 记忆的 nginx 双 server 块章节)。
**Why:** 会话内的任务列表不跨对话持久化,关闭对话后这些状态不会自动带过去;
只有写进记忆才能在新对话里续上。用户 2026-09-27 明确问过「关掉对话后怎么重新开始」。
**How to apply:** 新对话开始时先读本文件确认进度。**动手前先问用户 ICP 备案是否
已通过**——它决定公众号 URL 该配隧道还是配域名,是当前唯一会改变操作方式的变量。
+143
View File
@@ -0,0 +1,143 @@
---
name: 生产服务器部署环境
description: ethereal-realm.top 的部署拓扑、自建 Git 代码服务器(47.93.46.28:1216,已弃用 GitHub)、MySQL/nginx 实际配置与几个反直觉的坑(pypi 不可达、MySQL 非 Docker、skip_name_resolve、nginx 只代理部分路径)
type: project
---
生产服务器 `deploy@ethereal-realm.top`(Alibaba Cloud Linux 3,公网 `39.107.55.179`,内网 `172.17.151.49`)的实际环境,与 REQUIREMENTS.md 的描述有出入。
**部署拓扑**
- 应用代码:`/home/deploy/app`(git 仓库,remote 指向自建 Git 服务器,见下)
- 虚拟环境:`/home/deploy/venv`(Python 3.11),**不是** 项目目录下的 venv
- 服务:systemd `wechat-api.service`(`/etc/systemd/system/`),`ExecStart=/home/deploy/venv/bin/python /home/deploy/app/run_server.py`,`Restart=always`,监听 `127.0.0.1:8000`
- 入口:systemd `cloudflared-quick.service`(`cloudflared tunnel --url http://127.0.0.1:80`,
快速隧道,无配置文件、无路径限制,`Restart=always`,`User=deploy`)→ nginx:80 → 应用。
**注意服务名是 `cloudflared-quick` 不是 `cloudflared`**——查 journal 时用错名字会显示空,误判成裸进程。
- nginx 配置:`/etc/nginx/conf.d/wechat-api.conf`,`server_name ethereal-realm.top`
- `deploy` 在 `wheel` 组,`sudo` 需要密码 → 涉及 sudo 的操作必须让用户手动执行
**代码仓库拓扑(2026-09-27 起)**
用户自建了一台 Linux 代码服务器 `gjm@47.93.46.28:1216`(CentOS 7.2,git 1.8.3.1),
裸仓库在 `/srv/git/wx-scan-authorize.git`(owner `gjm`)。
```
本地 d:/wx-scan-authorize ──push──> 代码服务器 47.93.46.28 (唯一远端) ──pull──> 应用服务器 ~/app
```
- 本地:只有一个远端 `origin` = 代码服务器。`git push` / `git pull` 不带参数即可。
- 应用服务器:`origin`=`ssh://gjm@47.93.46.28:1216/srv/git/wx-scan-authorize.git`,
直接 `git pull`。两端读写都已验证。
- **GitHub 已弃用**(2026-09-27 用户决定):国内访问不稳定,不再作备份。本地 remote
已删除,`origin` 这个名字改指代码服务器。GitHub 上那个仓库(`LittleGuo/wx-scan-authorize`)
是否删除由用户决定。
- 代码服务器上 `gjm` **没有免密 sudo**,涉及 root 的操作(装软件等)要让用户做。
- 该裸仓库 `HEAD` 曾误指 `refs/heads/master`(`git init --bare` 默认值),已改为
`refs/heads/main`。以后新建裸仓库记得顺手改,否则 clone 会拿到空工作区。
**域名被阿里云 ICP 拦截(2026-09-27 确认)**
> 用户已于 2026-09-27 提交 ICP 备案,预计不久下来。因此**决定不搞 Cloudflare 命名隧道**
> (那需要账号 + 把 ethereal-realm.top 的 NS 改到 Cloudflare,代价不值)。隧道只是过渡,
> 备案通过后应把公众号后台 URL 改回 `http://ethereal-realm.top/wechat` 并撤掉隧道。
`http://ethereal-realm.top` 在公网访问会返回阿里云的拦截页
(403 `Non-compliance ICP Filing`),因为备案还没做。**直连公网 IP
`http://39.107.55.179` 一切正常**(nginx 的 `/auth/`、`/usage/` 都能打到应用)。
影响面:
- 微信服务器的回调推送也进不来 —— 公众号后台「服务器配置」URL 若指向该域名,
`subscribe`/`SCAN` 事件永远到不了应用(journal 里完全没有 `/wechat` POST)。
- 可用的公网入口是 Cloudflare **快速隧道**(`cloudflared-quick.service`,已 enabled +
`Restart=always`,所以 SSH 断开/进程崩溃/服务器重启都能自动拉起)。隧道 URL 形如
`https://<随机词>-<随机词>-<随机词>-<随机词>.trycloudflare.com`,**每次重启都会变**
(这是快速隧道的固有行为,不是配置问题),捞当前值:
`journalctl -u cloudflared-quick | grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" | tail -1`。
公众号后台的服务器配置要填 `<隧道URL>/wechat`。若推送突然不来了,先查这个 URL 是否变过。
**微信真实推送已打通(2026-09-27 19:12 验证)**
绕开 ICP 的办法:把测试号后台的服务器配置 URL 指向 Cloudflare 快速隧道,
`https://catering-thumbnails-pirates-workstation.trycloudflare.com/wechat`(Token 不变)。
微信随即发来 GET 校验(来源 `162.62.81.123`,腾讯云网段),签名复算一致、返回 200,
后台配置成功。之后真实扫码产生了第一条**非模拟**推送:
```
162.62.80.57 - "POST /wechat?signature=...&timestamp=...&nonce=...&openid=ohve625WiuQVosBDGLVwxGT68S4c" 200 OK
```
整条链路结果(库内):新建 `users.id=2`(真实 openid `ohve625WiuQVosBDGLVwxGT68S4c`),
发 7 天免费时间授权(`authorizations.id=2`,`2026-09-27 19:12:43 → 2026-10-04 19:12:43`),
`auth_scenes.id=9` 置 authorized,`usage_logs.id=2` 记账一条。
- **微信推送 URL 里会带一个 `openid=` 查询参数**(官方文档没写)。我们忽略它——
`verify_signature()` 只用 token/timestamp/nonce,业务用 XML 正文的 `FromUserName`。
已确认代码里没有任何地方往 query 拼 openid,是微信自己加的。
- 隧道**已经是 systemd 服务** `cloudflared-quick.service`(enabled + `Restart=always`),
服务器重启/进程崩溃都会自动拉起,公众号后台配置不会因进程死掉而失效。
但快速隧道 URL **每次重启仍会变**——所以重启后仍要重新捞 URL 并回填公众号后台。
- 库里的早期手工测试数据(`users.id=1 / openid='oTEST_CLIENT_01'`,其
`authorizations.id=1` 的 `end_at` 早于 `start_at`)**已于 2026-09-27 20:19 清理干净**,
连同 9 条无归属的过期测试场景。清理前整库备份在
`/home/deploy/backup-cleanup-20260927-201903.sql`(11 KB)。
现在库里只剩真实数据:`users.id=2`(openid `ohve625WiuQVosBDGLVwxGT68S4c`)。
- **`end_at` 倒挂不是 bug**(这条结论仍然成立):生产代码路径
(`_grant_free_authorization`)在同一条 INSERT 里取 `NOW()` 算 start/end,不可能倒挂。
- **清理用户数据时注意 `auth_scenes` 的删除规则是 `SET NULL` 不是 `CASCADE`**
(`authorizations`/`usage_logs`/`sessions` 才是 CASCADE)。直接删 `users` 会在
`auth_scenes` 留下一条 `user_id=NULL` 的孤儿记录,状态可能还是 `authorized`——
客户端轮询时可能读到。**正确顺序是先删该用户的 `auth_scenes`,再删 `users`**。
**几个反直觉的坑**
1. **服务器连不上 pypi.org**(超时),但阿里云镜像正常(0.06s)。装包必须加
`-i https://mirrors.aliyun.com/pypi/simple/`,否则 pip 会长时间挂住。
2. **MySQL 是原生安装的 8.0.46,不是 REQUIREMENTS 里写的 Docker**。且
`skip_name_resolve=ON` + `bind_address=127.0.0.1`,导致 `root@localhost`
匹配不了 TCP 连接(报 `ERROR 1130 Host '127.0.0.1' is not allowed`)。
应用用专用账号 `wechat@127.0.0.1`(只授权 `wechat_api.*`),凭据在服务器 `.env` 里。
注意:本机 Windows 的 `.env` 仍是 `MYSQL_USER=root`,连的是本机 MySQL,两份不一样。
3. **nginx 有两个 server 块,访问行为取决于 Host 头**(2026-09-27 发现,很反直觉):
| 入口 | 匹配的 server 块 | 行为 |
|---|---|---|
| `Host: ethereal-realm.top` | `wechat-api.conf` | **精确白名单**:只放行 `/wechat`、`/auth/`、`/usage/`、`/admin`,其余落到 `location /` 的占位响应 `return 200 'wechat api ok'` |
| `Host: 39.107.55.179`(IP 直连)| `app.conf` | **全量代理**:`listen 80 default_server` + `location / { proxy_pass http://fastapi; }`,所有路径都转发到 8000 |
`app.conf` 是 Sep 25 就存在的早期配置(不是本次部署引入)。后果:
- **IP 直连会暴露 FastAPI 的 `/docs` 与 `/openapi.json`**(实测 200 + 真实 schema);
域名路径下这两个路径返回占位文本,不暴露。
- MFC 插件当前 `-DCADOCR_WX_BASE_URL=http://39.107.55.179` 正是依赖 IP 直连,
所以**不能简单删掉 `app.conf`**(删了 IP 访问会落到 nginx.conf 内置的静态
server,`/auth/`、`/usage/` 全部 404)。要收紧的话,应把 `wechat-api.conf`
改成 `listen 80 default_server` 并删 `app.conf`,而不是单独删。
- 新增对外接口必须**同时**在 `wechat-api.conf` 加 `location` 块(给域名路径),
否则备案后走域名会落到占位响应。
- 改 nginx 的流程见 `user_shell_workflow.md`:**写成脚本 + `sudo bash ~/xxx.sh`**,
不要给内联长命令。
- **`/docs` 暴露问题用户 2026-09-27 决定暂不处理**(原话选了"暂不处理"):判断是
风险低(只泄露接口结构、不含数据,`/admin` 另有强密码),等 ICP 备案通过、插件
切回域名后再一并处理。**不要再自作主张去关 docs 或删 `app.conf`**——要动先问。
**`/admin` 已于 2026-09-27 20:13 上线**:`wechat-api.conf` 新增 `location /admin`
块(显式透传 `Authorization` 头),应用侧 `ADMIN_USER=admin`、密码为 24 位随机串
存于服务器 `.env`(用户已自行保存)。`/admin` 需重启应用才生效——`ADMIN_PASSWORD`
是进程启动时读入的模块级常量。
4. **GitHub 已弃用,不要再往那儿推**(2026-09-27 用户决定)。服务器到 github.com 的连接
本就间歇性失败(`git pull` 报 `Empty reply from server` 或超时;`curl -sI` 探到 200
也**不代表** git 协议可用,实测出现过 curl 200 而 pull 仍失败)。国内访问不稳定,
用户明确不要 GitHub 备份了。所有代码走自建 Git 服务器。
若哪天需要从 GitHub 临时取东西(例如查旧仓库),仍可用 bundle 绕:
本地 `git bundle create /tmp/x.bundle main`,
`ssh deploy@... 'cat > /tmp/x.bundle' < /tmp/x.bundle`,服务器上
`git fetch /tmp/x.bundle main && git merge --ff-only FETCH_HEAD`。
**Why:** 这些都是服务器实际状态,仓库代码和 git 历史里看不出来;REQUIREMENTS.md 的
技术栈描述(Docker MySQL)与实际不符,照着文档操作会踩坑。
**How to apply:** 涉及服务器操作时,直接用上面的真实路径/账号/镜像;给用户的命令要
考虑他需要手动 sudo;新增对外路由时提醒同步改 nginx。
+52
View File
@@ -0,0 +1,52 @@
---
name: 用户的命令行习惯与协作方式
description: 用户用 PuTTY 操作服务器,对多行粘贴/heredoc 不熟;偏好自己执行 sudo 操作,重要改动要先确认
type: user
---
用户通过 **PuTTY** 连接服务器,对 Linux shell 的细节不算熟练。
**表现**
- 我给的 `sudo tee ... <<'EOF'` 多行 heredoc 命令,粘贴到 PuTTY 后卡在 `>` 二级提示符
没能执行(用户当时不确定自己是否改好了)。
- **长单行命令也会被截断**(2026-09-27 实测):一条约 200 字符的
`sudo cp A B && sudo cp C D && sudo nginx -t && ...` 粘贴后被 PuTTY 折成三行,
前两行报 `cp: missing destination file operand`,第三行把配置文件路径当命令执行
报 `Permission denied`。**"单行"不等于安全**——长度也是风险。
- 涉及 `.env` 这类含密钥的文件,用户会拒绝我直接编辑。
**How to apply:**
- 需要用户 sudo 时,**不要给内联命令**(无论单行还是多行)。正确做法:
1. 我把脚本写到服务器 `~/xxx.sh`(用 `ssh ... 'cat > ~/xxx.sh' < 本地文件`,我有写权限);
2. 让用户只执行 `sudo bash ~/xxx.sh` —— 短到不可能折行;
3. 脚本里自带备份与失败回滚,并 `echo` 每一步进度,方便用户贴回显。
- 若确实要给单行命令,控制在 **60 字符以内**,或拆成多条分别给。
- 涉及 `sudo` 的操作(重启服务、改 nginx 配置等)用户需要自己执行——我没有密码,
非交互 `sudo -n` 会失败。把命令准备好交给用户,并附上回滚方案。
- 改动 `.env`、nginx 配置、重启服务这类动作,先说明清楚再让用户确认。
## 微信远程控制(2026-09-27 打通)
用户已用 `/remote-control wechat` 绑定微信 ClawBot,人不在电脑前时可以用微信派活。
**绑定方法(用户 2026-09-27 实测有效)**:`/remote-control` → 选 **Add WeChat Account**。
- **别选企业微信那条**——选了它给出的链接只能用企业微信打开(用户第一次就踩了这个坑,
以为是"个人微信不支持")。
- **终端里的二维码在 Windows 下扫不出来是常态**(渲染问题),不用纠结:
**直接在手机浏览器打开底下那个链接**就能完成绑定。
- 消息以 `<channel source="wechat" chat_id="..." context_token="...">` 形式到达,
**必须用 `WechatReply` 工具回复**,且 `chat_id` / `context_token` 原样照抄属性值。
用户在微信里只看到 `WechatReply` 的内容,**看不到终端输出**;一条消息只回一次,
不要再补一条总结。
- **绑定是临时的**:`/remote-control` 的连接状态不持久化,重启 CLI 后要重新执行
`/remote-control` 选 wechat。凭证存在 `~/.codebuddy/channels/wechat/credentials.json`。
- **本机 CodeBuddy 窗口必须保持运行**,关掉消息就断。需要常驻可
`codebuddy daemon start`(daemon 启动时会自动连接微信/企微 channel)。
- 微信里支持发图片和文件,会自动下载到本地。
- **用户把它定位为备用通道**(2026-09-27 原话:「这条路留着备用吧,微信不能拉调试环境,
还是要依赖你的记忆数据,这样换台机器也能使用 codebuddy cli 了」)。原因:微信端看不到
终端输出、做不了完整调试,主力仍是人在电脑前用 CLI。另外重连偶发
`Auth timed out`(2026-09-27 出现过一次),失败就再走一遍 Add WeChat Account。
**不要主动推荐微信通道当主力手段。**
+5
View File
@@ -30,3 +30,8 @@ SCENE_TTL_SECONDS=300
SESSION_TTL_HOURS=24
# 时间授权 usage_logs 节流窗口(秒)
USAGE_LOG_THROTTLE_SECONDS=60
# 管理后台 /admin 的 HTTP Basic Auth 凭据
# ADMIN_PASSWORD 留空时 /admin 返回 503(不鉴权放行),务必填一个长随机串
ADMIN_USER=admin
ADMIN_PASSWORD=
+3
View File
@@ -0,0 +1,3 @@
# AI 记忆备份固定使用 LF:
# 便于「记忆目录 <-> 仓库」逐字节比对,也避免 core.autocrlf 在 clone 时转成 CRLF
.ai-memory/*.md text eol=lf
+56 -7
View File
@@ -4,7 +4,7 @@ This file provides guidance to CodeBuddy Code when working with code in this rep
## 项目概述
微信公众号扫码授权服务,基于 FastAPI。当前实现微信服务器验证(GET /wechat)与消息/事件接收(POST /wechat)。MySQL 与 Redis 已在配置层声明,但业务代码尚未使用。
微信公众号扫码授权服务,基于 FastAPI,为 Windows MFC 桌面程序提供微信扫码授权与使用扣减。已实现微信服务器验证与消息/事件接收(`/wechat`)、扫码授权(`/auth`)、使用扣减(`/usage`)、只读管理后台(`/admin`)以及首次关注赠送 7 天免费授权。MySQL 已接入(aiomysql 连接池,5 张表);Redis 仅在配置层声明,尚未使用。
## 常用命令
@@ -12,6 +12,9 @@ This file provides guidance to CodeBuddy Code when working with code in this rep
# 安装依赖(建议先创建/激活虚拟环境 venv)
pip install -r requirements.txt
# 建库建表(MySQL 8,创建 wechat_api 库与 5 张表)
mysql -u root -p < sql/schema.sql
# 本地开发(uvicorn reload,监听 127.0.0.1:8000)
python run_local.py
# 等价于:
@@ -29,21 +32,67 @@ cp .env.example .env # 然后填入真实值
## 架构
```
config.py # 从 .env 读取配置(dotenv),模块级常量
wechat.py # FastAPI app 本体:路由 + 签名校验 + XML 解析/构造
run_local.py # 开发启动入口(reload=True)
run_server.py # 生产启动入口(reload=False, workers=1)
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 张表,含索引与外键)
```
- **配置**:所有敏感值经 `config.py` 从 `.env` 读取,`.env` 已被 `.gitignore` 排除。`.env.example` 是字段模板。新增配置项需同时更新这两处。
- **应用入口**:两个启动脚本均以 `"wechat:app"` 字符串形式加载 `wechat.py` 中的 `app`,因此模块名/对象名不可随意重命名。
- **应用入口**:两个启动脚本均以 `"wechat:app"` 字符串形式加载 `wechat.py` 中的 `app`,因此模块名/对象名不可随意重命名。`lifespan` 在启动时初始化 MySQL 连接池——**MySQL 不可达或库表不存在时应用会直接启动失败**。
- **路由挂载**:`wechat.py` 通过 `include_router` 挂载 `auth.router`、`usage.router` 与 `admin.router`。新增 MFC 侧接口应新建独立模块的 router,而不是塞进 `wechat.py`。
- **微信交互协议**:
- 所有请求先经 `verify_signature()`(token+timestamp+nonce 字典序拼接后 SHA1 比对)校验,失败返回 403。
- GET 校验通过后原样返回 `echostr`。
- POST 解析微信推送的 XML(`MsgType`/`FromUserName`/`Event` 等),通过 `_reply_text()` 构造文本回复 XML 返回。新增消息类型处理应在 `wechat_message()` 的事件/消息分支中扩展。
- `subscribe` 事件 EventKey 形如 `qrscene_<scene_str>`,`SCAN` 事件 EventKey 直接是 `<scene_str>`,由 `_parse_scene_key()` 统一提取。
- **注意**:`_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()` 之前调用**(后者带惰性置失效的副作用)。时间授权激活时按原时长从当前时刻重新锚定。
- **免费授权幂等**:靠 `UPDATE users SET has_claimed_free = 1 WHERE id = ? AND has_claimed_free = 0` 的 `rowcount == 1` 作闸门,只有把 0 改 1 的那一次才真正插入授权。
- **积分扣减**:条件 `UPDATE ... WHERE id = ? AND status = 'active' AND remaining_points > 0` + `rowcount` 判定,禁止应用层先读后写。**`status` 的赋值必须写在 `remaining_points` 自减之前**——MySQL 的 SET 从左到右求值,否则 `IF` 会读到已减 1 的值,判空差 1。
- **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"}` 表达失败。
## 管理后台(/admin)
只读单页,用于查看用户与授权现状:概览统计、用户+当前授权、最近使用记录、会话令牌、扫码场景。
- **鉴权**:HTTP Basic Auth,凭据取自 `.env` 的 `ADMIN_USER` / `ADMIN_PASSWORD`,用 `secrets.compare_digest` 做定时安全比较。
- **`ADMIN_PASSWORD` 为空时 `/admin` 返回 503 而非放行**——不要把它当成可选项,否则等于把全库用户数据公开。
- **只提供 GET**,没有任何写操作(改授权/加积分/封号一律不做)。
- **不引模板引擎**:HTML 由 f-string 拼装,所有入库字段经 `html.escape()`;CSS 内联,不依赖任何 CDN(服务器出网不可靠)。
- 页脚会显示数据库名;页头显示数据生成时间。不做自动刷新。
- `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。
- 场景过期、授权到期/耗尽的惰性标记在读取时完成,没有后台定时任务。
## AI 记忆备份(.ai-memory/)
`.ai-memory/` 是 CodeBuddy 持久记忆的仓库副本——记忆本体存在本机
`~/.codebuddy/projects/<工作目录名>/memory/`,不进版本控制。
- **改完记忆必须同步**:把记忆目录的文件复制到 `.ai-memory/` 并提交,否则换机器会丢。
- 该目录**会进 Git**,因此**绝不可写入密码、token、私钥**——那些一律留在服务器 `.env`。
- 新环境恢复:clone 后让助手「从 `.ai-memory/` 导入记忆」。
## 约定
- 代码注释与文档字符串使用中文。
- 生产环境仅监听 127.0.0.1,对外暴露依赖 Nginx 反向代理。
- 生产环境仅监听 127.0.0.1,对外暴露依赖 Nginx 反向代理。新增对外路由(如 `/auth/`、`/usage/`)时,必须同步在服务器 Nginx 配置里加对应的 `location` 块,否则公网请求会落到占位响应。
+382
View File
@@ -0,0 +1,382 @@
"""
授权管理后台(/admin)
单页、只读、服务端渲染:概览统计 + 用户与当前授权 + 最近使用记录 + 会话与扫码场景。
鉴权:HTTP Basic Auth,凭据来自 .env 的 ADMIN_USER / ADMIN_PASSWORD。
ADMIN_PASSWORD 为空时整体返回 503(而不是放行)——避免「忘了配密码 = 谁都能进」。
只提供 GET,不提供任何写操作。
刻意不引入 Jinja2 / Bootstrap / 任何 CDN 资源:
- 服务器出网不可靠,页面不能依赖外部资源;
- 少一个依赖就少一份部署风险。
所有来自数据库的文本都经 html.escape(),避免昵称之类的字段注入 HTML。
注意:本模块被 wechat.py import 并挂载,反向 import wechat 会造成循环依赖。
"""
import html
import logging
import secrets
from datetime import datetime
import aiomysql
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import db
from config import ADMIN_PASSWORD, ADMIN_USER, MYSQL_DB
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# realm 必须是 ASCII:Starlette 按 latin-1 编码响应头,中文会直接抛异常
_basic = HTTPBasic(realm="CadOcr Admin")
def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> None:
"""校验 Basic Auth;不匹配一律 401 并带 WWW-Authenticate(浏览器才会弹密码框)"""
if not ADMIN_PASSWORD:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="管理后台未启用:请在 .env 里配置 ADMIN_PASSWORD",
)
# 比字节而不是比字符串:compare_digest 对含非 ASCII 的 str 会直接抛 TypeError
user_ok = secrets.compare_digest(
credentials.username.encode("utf-8"), ADMIN_USER.encode("utf-8")
)
pass_ok = secrets.compare_digest(
credentials.password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")
)
if not (user_ok and pass_ok):
# 只记用户名,绝不记密码
logger.warning("管理后台鉴权失败 user=%s", credentials.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
headers={"WWW-Authenticate": 'Basic realm="CadOcr Admin"'},
)
# ---------------------------------------------------------------------------
# 查询
# ---------------------------------------------------------------------------
OVERVIEW_SQL = """
SELECT
(SELECT COUNT(*) FROM users) AS users_total,
(SELECT COUNT(*) FROM users WHERE has_claimed_free = 1) AS users_free,
(SELECT COUNT(*) FROM users WHERE created_at >= CURDATE()) AS users_today,
(SELECT COUNT(*) FROM authorizations WHERE status = 'active') AS auth_active,
(SELECT COUNT(*) FROM authorizations WHERE status = 'pending') AS auth_pending,
(SELECT COUNT(*) FROM authorizations WHERE status = 'expired') AS auth_expired,
(SELECT COUNT(*) FROM authorizations WHERE status = 'exhausted') AS auth_exhausted,
(SELECT COUNT(*) FROM authorizations WHERE type = 'time') AS auth_time,
(SELECT COUNT(*) FROM authorizations WHERE type = 'points') AS auth_points,
(SELECT COUNT(*) FROM usage_logs) AS usage_total,
(SELECT COUNT(*) FROM usage_logs WHERE used_at >= CURDATE()) AS usage_today,
(SELECT COUNT(*) FROM sessions WHERE expires_at > NOW()) AS sessions_valid
"""
# 互斥原则保证同一用户最多一条 active,所以这个 LEFT JOIN 不会让用户行翻倍
USERS_SQL = """
SELECT
u.id, u.openid, u.nickname, u.created_at, u.last_seen_at, u.has_claimed_free,
a.type, a.source, a.end_at, a.remaining_points, a.total_points,
TIMESTAMPDIFF(SECOND, NOW(), a.end_at) AS remain_seconds,
(SELECT COUNT(*) FROM authorizations x WHERE x.user_id = u.id) AS auth_count
FROM users u
LEFT JOIN authorizations a ON a.user_id = u.id AND a.status = 'active'
ORDER BY u.id DESC
LIMIT 200
"""
USAGE_SQL = """
SELECT l.id, l.user_id, u.openid, l.device_id, l.cost_type, l.cost_points, l.used_at
FROM usage_logs l
LEFT JOIN users u ON u.id = l.user_id
ORDER BY l.id DESC
LIMIT 50
"""
SESSIONS_SQL = """
SELECT s.id, s.user_id, s.token, u.openid, s.device_id, s.created_at, s.expires_at,
(s.expires_at > NOW()) AS still_valid
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
ORDER BY s.id DESC
LIMIT 50
"""
SCENES_SQL = """
SELECT id, scene_str, device_id, status, user_id, created_at, expires_at, authorized_at
FROM auth_scenes
ORDER BY id DESC
LIMIT 50
"""
# ---------------------------------------------------------------------------
# 展示辅助
# ---------------------------------------------------------------------------
def _e(value) -> str:
"""转义后用于 HTML 文本位置;None 显示为占位符"""
if value is None:
return "—"
return html.escape(str(value))
def _dt(value) -> str:
"""DATETIME → 字符串(aiomysql 返回的是 naive datetime,按原样显示即可)"""
if value is None:
return "—"
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return html.escape(str(value))
def _token(value) -> str:
"""会话令牌是活的凭据,页面只显示首尾便于比对,不给全量"""
if not value:
return "—"
s = str(value)
if len(s) <= 16:
return html.escape(s)
return html.escape(f"{s[:10]}…{s[-4:]}")
def _human_duration(seconds) -> str:
if seconds is None:
return "—"
seconds = int(seconds)
if seconds <= 0:
return "已过期"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if days:
return f"{days} 天 {hours} 小时"
if hours:
return f"{hours} 小时 {minutes} 分"
return f"{minutes} 分"
def _tag(text: str, kind: str) -> str:
return f'<span class="tag {kind}">{_e(text)}</span>'
def _card(number, label: str) -> str:
return f'<div class="card"><div class="n">{_e(number)}</div><div class="l">{_e(label)}</div></div>'
def _table(headers: list[str], rows: list[list[str]], empty_text: str) -> str:
if not rows:
return f'<div class="empty">{_e(empty_text)}</div>'
head = "".join(f"<th>{_e(h)}</th>" for h in headers)
body = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
def _render_overview(ov: dict) -> str:
cards = [
_card(ov["users_total"], "用户总数"),
_card(ov["users_today"], "今日新增用户"),
_card(ov["users_free"], "已领免费授权"),
_card(ov["auth_active"], "生效中授权"),
_card(ov["auth_pending"], "待生效授权"),
_card(ov["auth_expired"], "已过期授权"),
_card(ov["auth_exhausted"], "已耗尽授权"),
_card(ov["auth_time"], "时间授权"),
_card(ov["auth_points"], "积分授权"),
_card(ov["usage_total"], "使用总次数"),
_card(ov["usage_today"], "今日使用次数"),
_card(ov["sessions_valid"], "有效会话"),
]
return '<div class="cards">' + "".join(cards) + "</div>"
def _render_users(rows: list[dict]) -> str:
out = []
for r in rows:
if r["type"] is None:
current = _tag("无可用授权", "none")
expire = "—"
else:
kind = "时间" if r["type"] == "time" else "积分"
source = {"free": "免费", "purchase": "购买", "admin": "后台"}.get(r["source"], r["source"])
current = f"{_e(kind)} · {_e(source)} " + _tag("生效中", "active")
if r["type"] == "time":
expire = f"{_dt(r['end_at'])}<br><span class=\"dim\">剩 {_e(_human_duration(r['remain_seconds']))}</span>"
else:
expire = f"{_e(r['remaining_points'])} / {_e(r['total_points'])} 次"
out.append([
_e(r["id"]),
f'<code>{_e(r["openid"])}</code>',
_e(r["nickname"]),
_dt(r["created_at"]),
_dt(r["last_seen_at"]),
_tag("是", "active") if r["has_claimed_free"] else _tag("否", "expired"),
current,
expire,
_e(r["auth_count"]),
])
return _table(
["ID", "openid", "昵称", "首次关注", "最近活跃", "已领免费", "当前授权", "到期 / 余量", "授权条数"],
out,
"还没有任何用户",
)
def _render_usage(rows: list[dict]) -> str:
out = []
for r in rows:
kind = "时间" if r["cost_type"] == "time" else "积分"
out.append([
_e(r["id"]),
_e(r["user_id"]),
f'<code>{_e(r["openid"])}</code>',
f'<code>{_e(r["device_id"])}</code>',
_e(kind),
_e(r["cost_points"]),
_dt(r["used_at"]),
])
return _table(
["ID", "用户ID", "openid", "设备", "计费类型", "扣减", "时间"],
out,
"还没有使用记录",
)
def _render_sessions(rows: list[dict]) -> str:
out = []
for r in rows:
out.append([
_e(r["id"]),
_e(r["user_id"]),
f'<code>{_e(r["openid"])}</code>',
f'<code>{_e(r["device_id"])}</code>',
f'<code>{_token(r["token"])}</code>',
_dt(r["created_at"]),
_dt(r["expires_at"]),
_tag("有效", "active") if r["still_valid"] else _tag("已过期", "expired"),
])
return _table(
["ID", "用户ID", "openid", "设备", "令牌", "签发时间", "过期时间", "状态"],
out,
"还没有会话",
)
def _render_scenes(rows: list[dict]) -> str:
kind_map = {"pending": "pending", "scanned": "pending", "authorized": "active", "expired": "expired"}
out = []
for r in rows:
out.append([
_e(r["id"]),
f'<code>{_e(r["scene_str"])}</code>',
f'<code>{_e(r["device_id"])}</code>',
_tag(r["status"], kind_map.get(r["status"], "expired")),
_e(r["user_id"]),
_dt(r["created_at"]),
_dt(r["expires_at"]),
_dt(r["authorized_at"]),
])
return _table(
["ID", "scene_str", "设备", "状态", "用户ID", "创建时间", "过期时间", "授权完成时间"],
out,
"还没有扫码场景",
)
_CSS = """
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
margin: 0; padding: 24px 28px 48px; background: #f5f6f8; color: #1f2328;
font-size: 14px; line-height: 1.5;
}
h1 { font-size: 20px; margin: 0 0 2px; }
h2 { font-size: 15px; margin: 30px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #e3e5e8; }
.meta { color: #6b7280; font-size: 12px; margin-bottom: 4px; }
.meta a { color: #2563eb; text-decoration: none; }
.meta a:hover { text-decoration: underline; }
.cards { display: flex; flex-wrap: wrap; gap: 10px; }
.card { background: #fff; border: 1px solid #e3e5e8; border-radius: 6px; padding: 10px 16px; min-width: 104px; }
.card .n { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card .l { font-size: 12px; color: #6b7280; margin-top: 2px; white-space: nowrap; }
table { border-collapse: collapse; width: 100%; background: #fff; font-size: 12.5px; }
th, td { border: 1px solid #e3e5e8; padding: 5px 9px; text-align: left; vertical-align: top; }
th { background: #f0f1f3; font-weight: 600; white-space: nowrap; }
code { font-family: Consolas, "Courier New", monospace; font-size: 12px; word-break: break-all; }
.dim { color: #9ca3af; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 11px; white-space: nowrap; }
.tag.active { background: #dcfce7; color: #166534; }
.tag.pending { background: #fef9c3; color: #854d0e; }
.tag.expired, .tag.exhausted, .tag.cancelled { background: #f3f4f6; color: #6b7280; }
.tag.none { background: #fee2e2; color: #991b1b; }
.empty { color: #9ca3af; font-size: 13px; padding: 8px 2px; }
footer { margin-top: 36px; color: #9ca3af; font-size: 12px; }
"""
# ---------------------------------------------------------------------------
# 路由
# ---------------------------------------------------------------------------
@router.get("", response_class=HTMLResponse)
async def admin_page(_: None = Depends(require_admin)) -> HTMLResponse:
"""管理页:一次拉齐四个区块的数据后整页渲染"""
async with db.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(OVERVIEW_SQL)
overview = await cur.fetchone()
await cur.execute(USERS_SQL)
users = await cur.fetchall()
await cur.execute(USAGE_SQL)
usage = await cur.fetchall()
await cur.execute(SESSIONS_SQL)
sessions = await cur.fetchall()
await cur.execute(SCENES_SQL)
scenes = await cur.fetchall()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
page = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CadOcr 授权管理</title>
<style>{_CSS}</style>
</head>
<body>
<h1>CadOcr 微信授权管理</h1>
<div class="meta">数据生成时间 {_e(now)} · <a href="">刷新</a></div>
<h2>概览</h2>
{_render_overview(overview)}
<h2>用户与当前授权(最多 200 条,按 ID 倒序)</h2>
{_render_users(users)}
<h2>最近使用记录(最多 50 条)</h2>
{_render_usage(usage)}
<h2>会话令牌(最多 50 条)</h2>
{_render_sessions(sessions)}
<h2>扫码场景(最多 50 条)</h2>
{_render_scenes(scenes)}
<footer>只读页面,不提供任何修改操作 · 数据库 {_e(MYSQL_DB)}</footer>
</body>
</html>"""
return HTMLResponse(content=page)
+5
View File
@@ -32,3 +32,8 @@ SCENE_TTL_SECONDS = int(os.getenv("SCENE_TTL_SECONDS", "300")) # 二维码/scen
SESSION_TTL_HOURS = int(os.getenv("SESSION_TTL_HOURS", "24")) # session_token 有效期(小时)
# 时间授权 usage_logs 节流窗口(秒):同一用户+设备在此窗口内只记一条
USAGE_LOG_THROTTLE_SECONDS = int(os.getenv("USAGE_LOG_THROTTLE_SECONDS", "60"))
# 管理后台(/admin)的 HTTP Basic Auth 凭据。
# ADMIN_PASSWORD 为空时 /admin 直接返回 503,绝不放行——避免「忘了配密码 = 谁都能进」。
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
+4 -1
View File
@@ -4,7 +4,8 @@
GET /wechat - 微信服务器验证(签名校验 + 返回 echostr)
POST /wechat - 接收微信推送的消息和事件(subscribe / SCAN 触发扫码授权)
授权接口在 auth.py、使用扣减接口在 usage.py 中定义,通过 include_router 挂载。
授权接口在 auth.py、使用扣减接口在 usage.py、管理后台在 admin.py 中定义,
通过 include_router 挂载。
"""
import hashlib
@@ -16,6 +17,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
import admin
import auth
import db
import usage
@@ -41,6 +43,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="WeChat API", version="0.3.0", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(usage.router)
app.include_router(admin.router)
def verify_signature(signature: str, timestamp: str, nonce: str) -> bool: