Compare commits
10
Commits
cfb2a36c87
...
210b92b407
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210b92b407 | ||
|
|
76fb5868dc | ||
|
|
bc201210a5 | ||
|
|
553e416303 | ||
|
|
719f88105c | ||
|
|
877938c82c | ||
|
|
ad4854fdb3 | ||
|
|
6c5770b754 | ||
|
|
f167550957 | ||
|
|
3a8adda365 |
No files matched your search
@@ -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/ 并提交,不用用户提醒
|
||||
@@ -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、域名、用户名),
|
||||
没有任何凭据。
|
||||
@@ -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。
|
||||
@@ -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 该配隧道还是配域名,是当前唯一会改变操作方式的变量。
|
||||
@@ -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=...×tamp=...&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。
|
||||
@@ -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。
|
||||
**不要主动推荐微信通道当主力手段。**
|
||||
@@ -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=
|
||||
@@ -0,0 +1,3 @@
|
||||
# AI 记忆备份固定使用 LF:
|
||||
# 便于「记忆目录 <-> 仓库」逐字节比对,也避免 core.autocrlf 在 clone 时转成 CRLF
|
||||
.ai-memory/*.md text eol=lf
|
||||
+56
-7
@@ -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` 块,否则公网请求会落到占位响应。
|
||||
@@ -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)
|
||||
@@ -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,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:
|
||||
|
||||
Reference in new issue
Block a user