Author SHA1 Message Date
Blizzard abe543d4b2 docs: document Linux and BaoTa deployment workflow
Add a root README and a current deployment guide covering local setup, versioned Linux packaging, manual BaoTa updates with backups and rollback, production verification, and miniapp API_BASE configuration.
2026-11-04 21:32:39 +08:00
Blizzard b854d2873e feat: require couple binding and refine miniapp settings
Gate record features and storage entry points with invitation guidance; enforce record access in the backend. Add homepage greetings, right-swipe profile dismissal, and move the smoking switch to settings. Simplify API base configuration and remove the unused legacy tab bar component.
2026-11-04 21:27:03 +08:00
Blizzard 41bcb793da fix: support profile editor swipe and show account deletion entry 2026-11-03 22:38:01 +08:00
Blizzard 8be6d9b1ab feat: add account deletion, announcements and subscription renewal
Support personal records and persistent smoking visibility. Add upload quota preflight, file cleanup, privacy entry, image caching and revision-based page refresh across the backend, admin and mini program.
2026-11-03 22:06:47 +08:00
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
2026-11-02 23:40:49 +08:00
Blizzard 4934a69581 feat: migrate to PostgreSQL GORM and MinIO file storage
Use UUID models and soft deletion, persist file relationships with deduplicated monthly MinIO objects, and add admin file management. Update mini-program media handling and add local dev configuration.
2026-11-01 11:48:45 +08:00
CodeBuddy 0530523750 UGC 2026-10-30 23:46:55 +08:00
CodeBuddy bf99e52937 feat: 存储空间管理、虚拟支付接入与容量守卫
- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
2026-10-30 23:45:39 +08:00
Blizzard b7c6224e54 Refine subscription renewal button layout 2026-10-11 10:43:53 +08:00
Blizzard 31eae87a63 Fix subscription renewal and partner notification delivery 2026-10-11 10:32:30 +08:00
Blizzard c5cab1b945 Record subscription message delivery and add admin log page 2026-10-11 10:21:15 +08:00
Blizzard 7854d94e7b Add server and mini-program pagination for shared lists 2026-10-11 10:10:49 +08:00
Blizzard c0a3db5c8e 增加抽烟周月支数及买烟金额统计 2026-10-11 09:58:18 +08:00
Blizzard 02d200fe12 日记列表超过100字显示省略号并保留完整正文 2026-10-11 09:53:27 +08:00
Blizzard 2ffee9d7fb 为打卡详情记录增加分页并保持全量统计 2026-10-11 09:50:36 +08:00
Blizzard 7133ea583d 隐藏相册列表照片信息仅在预览展示 2026-10-11 09:37:38 +08:00
Blizzard 45c81aeaac 调整日记评论面板跟随键盘并保留输入草稿 2026-10-11 09:32:45 +08:00
Blizzard 1bafb01741 修复日记长文输入布局并减少输入时正文回传 2026-10-11 09:22:29 +08:00
Blizzard 5365e13963 按桌面past-go原样同步首页样式与后端通知逻辑 2026-10-11 09:16:38 +08:00
Blizzard 4e751410b3 将首页双方地址调整为同行显示 2026-10-11 09:13:45 +08:00
Blizzard a41c4d91cb 按桌面压缩包原样同步小程序开发工具配置 2026-10-11 09:12:49 +08:00
Blizzard 2b2adfc0d3 恢复与线上及桌面源码一致的生日同行排版 2026-10-11 09:11:02 +08:00
Blizzard 92f211fc3b 修复同步覆盖的首页生日排版与当天提示 2026-10-11 08:59:52 +08:00
Blizzard b10fc3c4ac 同步小程序与管理端更新,完善抽烟记录和解绑冷静期 2026-10-09 22:15:51 +08:00
Blizzard a68a4ef411 init: initial commit 2026-10-07 09:28:43 +08:00
207 changed files with 9638 additions and 3680 deletions
+9
View File
@@ -7,6 +7,9 @@ run/
# 本地配置(含密钥,不入库) # 本地配置(含密钥,不入库)
config.yaml config.yaml
config.dev.yaml
# 生产部署配置模板(含数据库密码,不入库;部署时复制到服务器后按需修改)
deploy/linux/config.prod.yaml
# 依赖与构建产物 # 依赖与构建产物
node_modules/ node_modules/
@@ -16,3 +19,9 @@ dist-ssr/
# 系统文件 # 系统文件
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 微信支付本地证书及私钥
past-go/cert/
# zap 日志及压缩轮转文件
log/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/past-go.iml" filepath="$PROJECT_DIR$/.idea/past-go.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+23
View File
@@ -0,0 +1,23 @@
# 我们 past
微信小程序、Go 后端与管理端共用此仓库。Go 模块位于 `past-go/`,不是仓库根目录;管理端构建后嵌入后端二进制。
| 目录 | 用途 |
| --- | --- |
| `weapp/` | 微信小程序 |
| `past-go/` | Go 后端、配置模板和 Makefile |
| `past-go/web/admin/` | React 管理端 |
| `deploy/linux/` | 当前生产 systemd 服务和 Nginx 配置参考 |
## 开发与部署
**协同开发请先阅读 [Linux / 宝塔部署文档](past-go/deploy/README.md)**:包含首次准备、本地开发、打包上传、备份更新、线上验证和回滚命令。
- [后端配置与业务说明](past-go/README.md)
- [Windows 部署说明](past-go/deploy/windows/README.md)(当前生产环境使用 Linux)
生产域名:`https://we.sundynix.cn`;管理端:`https://we.sundynix.cn/backend/admin/login`。
小程序 `weapp/config/env.js` 只导出 `API_BASE`,不自动判断环境。开发时手动填本机或局域网地址,上传体验版、正式版前改成 `https://we.sundynix.cn/api`。
真实 `config.yaml`、`config.dev.yaml` 和支付证书不会随 Git 下发。新成员需向项目负责人领取配置与证书,或从示例文件配置自己的开发环境;现有服务器更新时保留线上配置、证书及数据。
+46
View File
@@ -0,0 +1,46 @@
# Insert into the existing we.sundynix.cn HTTPS server block.
# Keep the existing SSL and ACME configuration. Replace matching locations.
location = / {
return 302 /backend/admin/login;
}
location = /api {
client_max_body_size 60m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /api/ {
client_max_body_size 60m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /backend/ {
client_max_body_size 60m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /healthz {
client_max_body_size 60m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=Past Go API
After=network-online.target postgresql.service redis-server.service
Wants=network-online.target
Requires=postgresql.service redis-server.service
[Service]
Type=simple
User=www
Group=www
WorkingDirectory=/home/workspace
ExecStart=/home/workspace/past-server -config /home/workspace/config.yaml
Environment=TZ=Asia/Shanghai
Restart=on-failure
RestartSec=3
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+5 -5
View File
@@ -4,7 +4,7 @@ GOOS ?= linux
GOARCH ?= amd64 GOARCH ?= amd64
VERSION ?= $(shell date +%Y%m%d-%H%M) VERSION ?= $(shell date +%Y%m%d-%H%M)
.PHONY: all web build build-windows build-local run test diff clean .PHONY: all web build build-windows build-local run dev test clean
all: web build all: web build
@@ -33,12 +33,12 @@ build-local:
run: build-local run: build-local
./run/$(BIN) -config config.yaml ./run/$(BIN) -config config.yaml
## 本地调试:使用不入库的 config.dev.yaml
dev: build-local
./run/$(BIN) -config config.dev.yaml
test: test:
go vet ./... && go test ./... go vet ./... && go test ./...
## PHP vs Go 响应对比(需先启动 dev 环境,见 ../dev/reset_dbs.sh)
diff:
cd ../dev && for s in scenarios/*.json; do ./reset_dbs.sh >/dev/null; python3 apidiff.py $$s | tail -1; done
clean: clean:
rm -rf dist run/$(BIN) web/admin/dist rm -rf dist run/$(BIN) web/admin/dist
+68 -43
View File
@@ -1,57 +1,82 @@
# 我们past后端(Go 版) # 我们 past 后端
`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。 开发、打包、宝塔更新、验证与回滚步骤见 **[Linux / 宝塔部署文档](deploy/README.md)**。仓库总览见 [根目录 README](../README.md)。以下为配置与业务说明。
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。 # 微信支付 API v3
## 目录 空间会员通过微信小程序 `wx.requestPayment` 支付,服务端使用官方 `wechatpay-go` SDK(与 plant-go 同样采用微信支付公钥验签模式)。线上域名为 `we.sundynix.cn`。
``` 配置文件中的 `wechat_pay` 包含商户号、证书序列号、APIv3 密钥、商户私钥、微信支付公钥 ID/路径与通知地址。设置 `enabled: true` 后启用;`appid` 留空时复用 `wechat.appid`,必须为商户已绑定的小程序。所有证书路径相对于所选 YAML 文件解析。
cmd/server/ 入口 `certificate_path` 可选,填写后校验证书商户号、序列号、有效期及私钥匹配;请求签名只使用商户私钥。
internal/config/ config.yaml 读取 本机证书放在已忽略 Git 的 `cert/`,真实密钥仅存在本地 `config.dev.yaml`,模板不含密钥。
internal/store/ MySQL 访问(行类型对齐 PDO)、schema.go = install.php 的移植
internal/httpapi/ gin 路由、Ctx($_GET/$_POST/$input/$_FILES 等价物)、鉴权 接口:
internal/handler/ 121 个 action,按模块拆分(user/couple/timeline/album/diary/songs/checkin/period/pet/misc/admin)
internal/upload/ handleUpload 等价物;文件落在 <data_dir>/uploads/ - `POST /api/storageCreateOrder`:传服务端当前上架套餐的 `planKey`(见 `storageInfo`),并携带页面取得的 `expectedPriceCents` 和 `expectedPlanVersion`;价格由服务端收费设置决定。先持久化订单,再调用预下单,返回 `outTradeNo` 与 `payment` 签名参数。
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session - `GET /api/storageOrderStatus?outTradeNo=...`:仅允许查询本人订单,必要时向微信主动查单并补开通。
internal/geo/ 腾讯位置服务 - `POST https://we.sundynix.cn/api/wechatPayNotify`:无需用户登录,通过微信支付签名校验、APIv3 AES-GCM 解密后核对商户、AppID、付款人、金额和币种;数据库事务提交后返回 204。
internal/notify/ cron_notify.php 的进程内定时版
web/admin/ React 管理后台(dist/ 被 embed) 订单保存支付身份和金额快照,并通过行锁、交易号检查实现重复通知幂等。会员已到期时从当前时间续期,未到期时累计续期;更新会员和订单状态在同一事务完成。服务每分钟补查最近 24 小时待支付订单,退出时停止。
管理端「存储空间管理」只配置免费额度和套餐价格,并查看支付状态与订单记录。小程序以服务端查单结果确认会员开通。
本地调试不能接收公网回调,但小程序支付后主动查单可以补开通;上线需让上述 HTTPS 回调能访问部署的新后端。
此通道为正式商户支付;真实支付需使用商户绑定 AppID 下的真实微信登录用户和真机。自动化测试使用签名及加密测试数据,不产生真实扣款。
# 本地 dev 调试
在 `past-go/` 目录执行 `make dev`,使用 `config.dev.yaml` 启动,监听 `:8080`。
本地配置包含 AppSecret,已加入 Git 忽略;新环境可复制 `config.dev.example.yaml` 后填写。
管理端地址:`http://localhost:8080/backend/admin/`。
小程序在 `weapp/config/env.js` 中手动配置 `API_BASE`。本地调试填写本机或局域网地址;上传体验版、正式版前改为 `https://we.sundynix.cn/api`。
开发工具需关闭合法域名校验(项目已设置);真机调试将 dev 地址改为电脑的局域网 IP。
小程序 AppID 为 `wxb463820bf36dd5d6`,AppSecret 仅存放在后端本地配置。
dev 继续使用当前 `past-go` 数据库与 MinIO bucket,并非独立测试数据。
日志写入 `run/dev/log/YYYY-MM-DD/`,微信 access_token 缓存在 Redis,定时补发通知关闭。
新小程序的订阅模板需在对应平台获取后填写。
# PostgreSQL / GORM 数据层(当前版本)
数据库为 `192.168.100.127:5432/past-go`,账号 `root`;连接配置见 `config.example.yaml`。
复制为本地 `config.yaml` 并填写微信配置。启动时先连接 `postgres` 管理库检查并创建目标库,再执行 GORM AutoMigrate。
也可以只运行迁移:
```sh
go run ./cmd/migrate -config config.yaml
``` ```
## 运行 模型定义在 `internal/model/models.go`,所有模型嵌入 `BaseModel`:`Id string`、`CreatedAt`、`UpdatedAt`、`DeletedAt`。
主键与关联 ID 使用 PostgreSQL `uuid` 类型;主键默认 `gen_random_uuid()`,不自增。
表名统一为 `past_` + 单数 snake_case(例如 `User` → `past_user`、`DiaryComment` → `past_diary_comment`);字段按 `UserId` → `user_id` 命名。
启动时会将旧 `past-` 前缀的表无损重命名为 `past_`,保留 UUID、数据和索引。
```bash 新增、修改、删除通过 GORM 模型执行;复杂报表通过 GORM Raw 执行,并统一过滤所有关联表及子查询中的 `deleted_at`。
cp config.example.yaml config.yaml # 填写数据库 / 微信配置 删除保留原行及附件;活跃记录的唯一索引带 `WHERE deleted_at IS NULL`,删除后可以重新创建同业务键的记录。
go build -o past-server ./cmd/server 用户对外的六位展示编号是独立业务字段,继续使用数据库序列分配;它不是 UUID 主键。情侣空间编号为 `CP` + 不带连字符的情侣 UUID。
./past-server -config config.yaml
```
运行目录布局(`data_dir`,默认为二进制所在目录): 本次创建的是新的 PostgreSQL 库,不会导入旧 MySQL 数据。历史备份如需迁入,需要单独完成数据转换和旧 ID 到 UUID 的关联映射。
``` 验证:`go test ./...`、`go vet ./...`;在设置 `PAST_TEST_POSTGRES_DSN` 后,可运行 `go test ./internal/store -run TestPostgresIntegration -v` 验证真实 PostgreSQL(测试数据全部回滚)。
data_dir/
├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移)
└── data/ access_token.cache 等
```
URL 映射: MinIO 配置读取 `minio` 节点。`past_files` 保存 UUID、size、suffix、content_type、object_key、md5、sha256、url、bucket 等元数据;对象键格式为 `YYYY/MM/<md5>.<suffix>`。同内容并发上传通过 PostgreSQL 锁和唯一索引去重,不重复上传对象。
| URL | 说明 | 头像、相册封面、照片、打卡图片和背景使用带外键的文件 ID 字段;日记/记忆日图片使用 `past_record_file` 按 position 保存多图顺序。旧接口的路径字段在数据库中仅保存 ID 镜像,响应时解析为稳定文件访问地址;新请求可直接提交 `fileId`、`avatarFileId`、`coverFileId`、`imageFileIds` 等字段。`past_file_upload` 独立记录每个上传用户,既保留共享去重,也保证权限与容量统计正确。
|---|---|
| `/api/接口名` | 小程序、管理后台、上传统一入口 |
| `/backend/api/index.php?action=…` | 旧客户端兼容入口 |
| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) |
| `/backend/admin/` | 管理后台 SPA |
| `/healthz` | 健康检查 |
## 与 PHP 版的差异(有意为之) 上传成功返回 `fileId`、`path`、`url`、`file`、`duplicate`。`/api/files/:fileId` 由后端通过短期 MinIO 签名请求代理读取文件,返回稳定访问地址,并提供图片缓存与 ETag 条件请求,不修改 bucket 的公共访问策略。删除业务关联只做逻辑删除,不删除共享对象。容量检查在内容去重后于事务内执行,同文件不会重复占用同一空间的额度。
见 `../dev/API_CONTRACT.md` 与对比测试 `../dev/apidiff.py`。已知的 PHP 原有 bug 处理: 后台 **文件管理**(`/backend/admin/files`)通过管理员接口 `/api/adminFiles` 查看全部文件、上传用户、预览、对象路径及元数据,支持关键词、类型、日期筛选和分页。普通用户不能调用文件管理接口。
- `*_add` 接口返回的 `id` 改为真实记录 id(PHP 返回的是 activity_log 的 id)。 真实 PostgreSQL/MinIO 回归:设置 `PAST_TEST_POSTGRES_DSN` 后运行 `go test ./...`。测试读取本地 `config.yaml` 中的 MinIO 连接,可通过 `PAST_TEST_CONFIG` 指定其他配置文件的绝对路径,使用隔离的 schema/bucket,测试结束清理;微信/地图外部请求使用模拟依赖。
- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。
- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。 构建及部署:在 `past-go/` 目录执行 `make build-local` 生成本机服务;管理端资源已存在时可直接构建,修改管理端后先执行 `npm --prefix web/admin run build`。
- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。 Linux 发布使用 `make all`,Windows 发布使用 `make web build-windows`。
- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。 部署配置见 [deploy/README.md](deploy/README.md) 与 [Windows 部署说明](deploy/windows/README.md)。
日志由独立 `internal/logging` 包使用 zap 管理,自动创建 `data_dir/log/YYYY-MM-DD/`,按级别保存 JSON 日志。默认保留 30 天,每个文件达到 100MB 轮转并压缩,备份数默认 10;可通过 `log` 配置调整。
Redis 是服务启动依赖,通过 `redis.addr/password/db/key_prefix` 配置;本地、测试和生产应使用不同前缀或 DB。微信 token 按 AppID 缓存,按 `expires_in` 提前过期,通过 Redis 锁协调多实例刷新,不再读写 `data/access_token.cache`。Redis 不可用时启动失败。
新数据库的管理员账号密码为 `past / past`。已有数据库可显式执行 `./past-server -config config.yaml -reset-admin` 更新为该账号密码,保留原 `admin` 的 ID;正常启动不会重置已修改的密码。
存储收费支持多档套餐,在管理端「存储空间 → 存储收费设置」编辑名称、容量 MB(0 为无限)、有效月数、价格、上架状态和排序。套餐变更原子保存并检查配置版本,已购买权益及历史订单不随套餐修改或下架改变。新表 `past_storage_plan` 保存套餐,`past_storage_entitlement` 保存每笔权益快照;同容量续费顺延,不同容量各自到期,用户与当前伴侣共享最大有效容量,有限套餐不会绕过上传限制。到期后已有文件可查看,新增上传按剩余有效容量检查。
+13 -52
View File
@@ -1,18 +1,15 @@
// dbcheck runs only SELECT/SHOW statements. It never initializes or migrates a database. // dbcheck runs only SELECT statements. It never initializes or migrates a database.
package main package main
import ( import (
"context"
"database/sql"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
_ "github.com/go-sql-driver/mysql"
"past/internal/config" "past/internal/config"
"past/internal/store"
) )
type result struct { type result struct {
@@ -21,43 +18,15 @@ type result struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
func query(db *sql.DB, name, statement string) result { func query(db *store.DB, name, statement string) result {
r := result{Name: name} r := result{Name: name}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) rows, err := db.Query(statement)
defer cancel()
rows, err := db.QueryContext(ctx, statement)
if err != nil { if err != nil {
r.Error = err.Error() r.Error = err.Error()
return r return r
} }
defer rows.Close() for _, row := range rows {
columns, err := rows.Columns() r.Rows = append(r.Rows, map[string]any(row))
if err != nil {
r.Error = err.Error()
return r
}
for rows.Next() {
values := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range values {
ptrs[i] = &values[i]
}
if err := rows.Scan(ptrs...); err != nil {
r.Error = err.Error()
return r
}
row := map[string]any{}
for i, key := range columns {
if b, ok := values[i].([]byte); ok {
row[key] = string(b)
} else {
row[key] = values[i]
}
}
r.Rows = append(r.Rows, row)
}
if err := rows.Err(); err != nil {
r.Error = err.Error()
} }
return r return r
} }
@@ -73,30 +42,22 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
db, err := sql.Open("mysql", cfg.DSN()) db, err := store.Open(cfg.DSN())
if err != nil { if err != nil {
return fmt.Errorf("cannot open database connection") return fmt.Errorf("database connection failed: %w", err)
} }
defer db.Close() defer db.Close()
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("database connection failed; check the original config and MySQL service locally")
}
report := []result{ report := []result{
query(db, "database", "SELECT DATABASE() AS database_name, VERSION() AS version, NOW() AS database_time"), query(db, "database", "SELECT CURRENT_DATABASE() AS database_name, VERSION() AS version, NOW() AS database_time"),
query(db, "binary_log_settings", "SHOW VARIABLES WHERE Variable_name IN ('log_bin','log_bin_basename','binlog_format','binlog_row_image','datadir')"),
query(db, "binary_log_files", "SHOW BINARY LOGS"),
query(db, "relationship_status", "SELECT status,COUNT(*) AS records FROM couples GROUP BY status"), query(db, "relationship_status", "SELECT status,COUNT(*) AS records FROM couples GROUP BY status"),
query(db, "relationship_mismatches", "SELECT c.id,c.status,c.user1_id,c.user2_id,u1.couple_id AS user1_current_couple,u2.couple_id AS user2_current_couple FROM couples c LEFT JOIN users u1 ON u1.id=c.user1_id LEFT JOIN users u2 ON u2.id=c.user2_id WHERE c.status='active' AND (u1.id IS NULL OR u2.id IS NULL OR COALESCE(u1.couple_id,0)<>c.id OR COALESCE(u2.couple_id,0)<>c.id) ORDER BY c.id"), query(db, "relationship_mismatches", "SELECT c.id,c.status,c.user1_id,c.user2_id,u1.couple_id AS user1_current_couple,u2.couple_id AS user2_current_couple FROM couples c LEFT JOIN users u1 ON u1.id=c.user1_id LEFT JOIN users u2 ON u2.id=c.user2_id WHERE c.status='active' AND (u1.id IS NULL OR u2.id IS NULL OR u1.couple_id IS DISTINCT FROM c.id OR u2.couple_id IS DISTINCT FROM c.id) ORDER BY c.id"),
query(db, "migration_marker", "SELECT `key`,`value` FROM settings WHERE `key`='account_records_v1'"), query(db, "migration_marker", "SELECT key,value FROM settings WHERE key='account_records_v1'"),
} }
for _, table := range []string{"users", "couples", "diaries", "albums", "photos", "timeline", "songs", "checkins", "period_records", "period_daily_records", "period_sex_records", "personal_period_records", "personal_period_daily_records", "personal_period_sex_records", "smoking_wallets", "smoking_entries"} { for _, table := range []string{"users", "couples", "diaries", "albums", "photos", "timeline", "songs", "checkins", "period_records", "period_daily_records", "period_sex_records", "smoking_wallets", "smoking_entries"} {
report = append(report, query(db, "count_"+table, "SELECT COUNT(*) AS records FROM "+table)) report = append(report, query(db, "count_"+table, "SELECT COUNT(*) AS records FROM "+table))
} }
for _, table := range []string{"diaries", "albums", "timeline", "songs", "checkins"} { for _, table := range []string{"diaries", "albums", "timeline", "songs", "checkins"} {
report = append(report, query(db, "space_"+table, "SELECT d.couple_id,c.status,COUNT(*) AS records,SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author,SUM(CASE WHEN d.created_by IS NULL OR d.created_by=0 THEN 1 ELSE 0 END) AS missing_author FROM "+table+" d LEFT JOIN couples c ON c.id=d.couple_id GROUP BY d.couple_id,c.status ORDER BY d.couple_id")) report = append(report, query(db, "space_"+table, "SELECT d.couple_id,c.status,COUNT(*) AS records,SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author,SUM(CASE WHEN d.created_by IS NULL THEN 1 ELSE 0 END) AS missing_author FROM "+table+" d LEFT JOIN couples c ON c.id=d.couple_id GROUP BY d.couple_id,c.status ORDER BY d.couple_id"))
} }
f, err := os.CreateTemp(filepath.Dir(exe), "dbcheck-report-*.json") f, err := os.CreateTemp(filepath.Dir(exe), "dbcheck-report-*.json")
if err != nil { if err != nil {
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
"past/internal/config"
"past/internal/store"
)
func run() error {
path := flag.String("config", "config.yaml", "配置文件路径")
flag.Parse()
cfg, err := config.Load(*path)
if err != nil {
return err
}
if err := store.EnsureDatabase(cfg.DSN()); err != nil {
return err
}
db, err := store.Open(cfg.DSN())
if err != nil {
return err
}
defer db.Close()
if err := db.InitSchema(); err != nil {
return err
}
if err := db.MigrateSmokingRecords(); err != nil {
return err
}
fmt.Println("PostgreSQL 数据库与 GORM 表结构迁移完成")
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+68 -31
View File
@@ -5,8 +5,7 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io" "go.uber.org/zap"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -15,14 +14,17 @@ import (
"time" "time"
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用 _ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
"past/internal/cache"
"past/internal/config" "past/internal/config"
"past/internal/geo" "past/internal/geo"
"past/internal/handler" "past/internal/handler"
"past/internal/httpapi" "past/internal/httpapi"
"past/internal/logging"
"past/internal/notify" "past/internal/notify"
"past/internal/store" "past/internal/store"
"past/internal/upload" "past/internal/upload"
"past/internal/wechat" "past/internal/wechat"
"past/internal/wxpay"
"past/web" "past/web"
) )
@@ -31,6 +33,7 @@ var version = "dev"
func main() { func main() {
cfgPath := flag.String("config", "config.yaml", "配置文件路径") cfgPath := flag.String("config", "config.yaml", "配置文件路径")
resetAdmin := flag.Bool("reset-admin", false, "将管理端账号密码设置为 past/past 后退出")
showVer := flag.Bool("version", false, "显示版本") showVer := flag.Bool("version", false, "显示版本")
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop") svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
flag.Parse() flag.Parse()
@@ -55,6 +58,26 @@ func main() {
} }
} }
if *resetAdmin {
cfg, err := config.Load(*cfgPath)
if err == nil {
var db *store.DB
db, err = store.Open(cfg.DSN())
if err == nil {
err = db.ResetDefaultAdmin()
if pool, e := db.ORM.DB(); e == nil {
_ = pool.Close()
}
}
}
if err != nil {
fmt.Fprintln(os.Stderr, "管理员重置失败:", err)
os.Exit(1)
}
fmt.Println("管理员账号已设置为 past")
return
}
if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) { if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) {
return return
} }
@@ -78,43 +101,52 @@ func serve(cfgPath string, stop <-chan struct{}) {
cfg, err := config.Load(cfgPath) cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
slog.Error("配置加载失败", "err", err) fmt.Fprintln(os.Stderr, "配置加载失败:", err)
os.Exit(1) os.Exit(1)
} }
// 确保必要目录存在(对应 config.php 中的 $dirs) log, closeLog, err := logging.New(cfg.LogDir(), cfg.Log)
for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} { if err != nil {
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755) fmt.Fprintln(os.Stderr, "日志初始化失败:", err)
os.Exit(1)
} }
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径) defer closeLog()
logOut := io.Writer(os.Stdout) undoZap := zap.ReplaceGlobals(log)
if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { defer undoZap()
logOut = io.MultiWriter(os.Stdout, f) undoStdLog := zap.RedirectStdLog(log)
defer f.Close() defer undoStdLog()
redisClient, err := cache.Open(cfg.Redis)
if err != nil {
zap.S().Errorw("Redis 初始化失败", "err", err)
os.Exit(1)
} }
slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo}))) defer redisClient.Close()
zap.L().Info("Redis 已连接")
if err := store.EnsureDatabase(cfg.DSN()); err != nil {
zap.S().Errorw("创建 PostgreSQL 数据库失败", "err", err)
os.Exit(1)
}
db, err := store.Open(cfg.DSN()) db, err := store.Open(cfg.DSN())
if err != nil { if err != nil {
slog.Error("数据库连接失败", "err", err) zap.S().Errorw("数据库连接失败", "err", err)
os.Exit(1) os.Exit(1)
} }
db.UploadDir = cfg.UploadDir()
db.NumericAsString = cfg.PHPCompat < "8.1" db.NumericAsString = cfg.PHPCompat < "8.1"
if err := db.InitSchema(); err != nil { if err := db.InitSchema(); err != nil {
slog.Error("数据库初始化失败", "err", err) zap.S().Errorw("数据库初始化失败", "err", err)
os.Exit(1) os.Exit(1)
} }
if err := db.MigrateSmokingRecords(); err != nil { if err := db.MigrateSmokingRecords(); err != nil {
slog.Error("抽烟库存迁移失败", "err", err) zap.S().Errorw("抽烟库存迁移失败", "err", err)
os.Exit(1) os.Exit(1)
} }
if err := db.AdvanceUnbindings(); err != nil { if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑状态处理失败", "err", err) zap.S().Errorw("解绑状态处理失败", "err", err)
os.Exit(1) os.Exit(1)
} }
go db.RunUnbindingWorker() go db.RunUnbindingWorker()
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db) wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, redisClient, cfg.Redis.KeyPrefix, db)
app := &httpapi.App{ app := &httpapi.App{
Cfg: cfg, Cfg: cfg,
DB: db, DB: db,
@@ -122,23 +154,28 @@ func serve(cfgPath string, stop <-chan struct{}) {
Geo: geo.New(cfg.TencentMapKey), Geo: geo.New(cfg.TencentMapKey),
Admin: httpapi.NewAdminSessions(), Admin: httpapi.NewAdminSessions(),
} }
uploader := &upload.Uploader{Dir: cfg.UploadDir(), MaxSize: cfg.MaxUploadMB * 1024 * 1024, Checker: wx} if cfg.WechatPay.Enabled {
app.WechatPay, err = wxpay.New(cfg.WechatPay)
if err != nil {
zap.S().Errorw("微信支付初始化失败", "err", err)
os.Exit(1)
}
go handler.RunWechatOrderQuery(db, app.WechatPay, stop, time.Minute)
}
uploader, err := upload.New(cfg.Minio, db, cfg.MaxUploadMB*1024*1024, wx)
if err != nil {
zap.S().Errorw("MinIO 初始化失败", "err", err)
os.Exit(1)
}
router := httpapi.NewRouter(app, web.AdminFS()) router := httpapi.NewRouter(app, web.AdminFS())
go uploader.RunFileCleanupWorker()
handler.Register(router, uploader) handler.Register(router, uploader)
slog.Info("已注册 action", "count", len(router.Actions())) zap.S().Infow("已注册 action", "count", len(router.Actions()))
if cfg.NotifyIntervalMin > 0 { if cfg.NotifyIntervalMin > 0 {
go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute) go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute)
} }
// 虚拟支付兜底查单:发货推送丢失/延迟时,通过 query_order 主动补发货。
if cfg.VirtualPay.OfferID != "" {
interval := time.Duration(cfg.NotifyIntervalMin) * time.Minute
if interval <= 0 {
interval = 5 * time.Minute
}
go handler.RunStorageOrderQuery(db, wx, cfg, interval)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
@@ -149,9 +186,9 @@ func serve(cfgPath string, stop <-chan struct{}) {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
go func() { go func() {
slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir) zap.S().Infow("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("服务异常退出", "err", err) zap.S().Errorw("服务异常退出", "err", err)
os.Exit(1) os.Exit(1)
} }
}() }()
@@ -160,5 +197,5 @@ func serve(cfgPath string, stop <-chan struct{}) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
srv.Shutdown(ctx) srv.Shutdown(ctx)
slog.Info("服务已停止") zap.S().Infow("服务已停止")
} }
+1 -1
View File
@@ -73,7 +73,7 @@ func serviceControl(cmd, cfgPath string) error {
} }
s, err := m.CreateService(svcName, exe, mgr.Config{ s, err := m.CreateService(svcName, exe, mgr.Config{
DisplayName: "我们past Go 服务 (past-server)", DisplayName: "我们past Go 服务 (past-server)",
Description: "past.sundynix.cn 后端 API 与管理后台", Description: "we.sundynix.cn 后端 API 与管理后台",
StartType: mgr.StartAutomatic, StartType: mgr.StartAutomatic,
}, "-config", abs) }, "-config", abs)
if err != nil { if err != nil {
+53
View File
@@ -0,0 +1,53 @@
# 本地 dev 配置(模板,复制为 config.dev.yaml 后补充密钥)
listen: ":8080"
# 本地运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储使用 MinIO
data_dir: "./run/dev"
debug: true
db:
host: 192.168.100.127
port: 5432
name: past-go
user: root
pass: root
sslmode: disable
wechat:
appid: wxb463820bf36dd5d6
secret: "" # 在本地 config.dev.yaml 中填写 AppSecret
notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" # 操作与打卡通知共用模板(15198)
tencent_map_key: ""
max_upload_mb: 50
# 定时补发通知间隔(分钟),0 关闭
notify_interval_min: 0 # 本地调试关闭定时补发
minio:
endpoint: https://s3.sundynix.cn
access_key: your_minio_access_key
secret_key: your_minio_secret_key
bucket: past-go
use_ssl: true
# 微信支付 API v3(直连商户 / 小程序 JSAPI)
wechat_pay:
enabled: false
appid: "wxb463820bf36dd5d6"
mch_id: "your_mch_id"
certificate_serial: "your_certificate_serial"
certificate_path: "" # 可选:填写后启动时校验证书、商户号及私钥的一致性
private_key_path: "cert/apiclient_key.pem"
public_key_id: "your_wechatpay_public_key_id"
public_key_path: "cert/pub_key.pem"
api_v3_key: ""
notify_url: "https://we.sundynix.cn/api/wechatPayNotify"
# Redis 缓存微信 token;log/ 按日期和级别落盘
redis:
addr: 127.0.0.1:6379
username: ""
password: ""
db: 0
key_prefix: "past-go:"
log:
level: info
console: true
retention_days: 30
max_size_mb: 100
max_backups: 10
+40 -15
View File
@@ -3,25 +3,50 @@ listen: ":8080"
# 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录 # 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录
data_dir: "" data_dir: ""
db: db:
host: 127.0.0.1 host: 192.168.100.127
port: 3306 port: 5432
name: past name: past-go
user: past user: root
pass: your_db_password pass: root
sslmode: disable
wechat: wechat:
appid: your_appid appid: your_appid
secret: your_secret secret: your_secret
notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8 notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" # 操作与打卡通知共用模板(15198)
tencent_map_key: "" tencent_map_key: ""
max_upload_mb: 50 max_upload_mb: 50
# 定时补发通知间隔(分钟),0 关闭 # 定时补发通知间隔(分钟),0 关闭
notify_interval_min: 5 notify_interval_min: 5
# 微信小程序虚拟支付(米大师虚拟支付2.0) minio:
virtual_pay: endpoint: https://s3.sundynix.cn
appid: wxdc40d0c51cf8fe1e # 小程序 AppID(默认复用 wechat.appid) access_key: your_minio_access_key
offer_id: "1450687270" # 支付应用 ID secret_key: your_minio_secret_key
app_key: "" # 正式环境 AppKey(上线后填写) bucket: past-go
sandbox_key: "OVuTGBk28DKbvyKek7KrbDRyfZDKpk4P" # 沙箱环境 AppKey use_ssl: true
env: 1 # 0-正式环境 1-沙箱环境
notify_token: "2029807d6e6441149c138eee3254c27c" # 支付结果回调 Token(微信后台消息推送配置) # 微信支付 API v3(直连商户 / 小程序 JSAPI)
notify_aes_key: "" # 支付结果回调 EncodingAESKey(可选) wechat_pay:
enabled: false
appid: "wxb463820bf36dd5d6"
mch_id: "your_mch_id"
certificate_serial: "your_certificate_serial"
certificate_path: "" # 可选:填写后启动时校验证书、商户号及私钥的一致性
private_key_path: "cert/apiclient_key.pem"
public_key_id: "your_wechatpay_public_key_id"
public_key_path: "cert/pub_key.pem"
api_v3_key: ""
notify_url: "https://we.sundynix.cn/api/wechatPayNotify"
# Redis 缓存微信 token;log/ 按日期和级别落盘
redis:
addr: 127.0.0.1:6379
username: ""
password: ""
db: 0
key_prefix: "past-go:"
log:
level: info
console: true
retention_days: 30
max_size_mb: 100
max_backups: 10
+282 -43
View File
@@ -1,60 +1,299 @@
# 20261008 原权限兼容与抽烟功能 # Linux / 宝塔部署文档
## 当前行为 本文是当前部署入口,适用于 PostgreSQL + Redis + MinIO + 微信支付 API v3。以下操作在本地构建后,通过宝塔「文件」上传,再在宝塔「终端」手动执行;不需要配置计划任务,也不要求开发人员拥有 SSH。
- 日记、相册、记忆日、歌曲和普通打卡恢复原版按情侣成员作者查询;历史 couple_id 不再成为新增过滤条件。未绑定展示方式恢复原版。 ## 1. 当前环境与目录
- 姨姨继续使用 period_records、period_daily_records、period_sex_records,恢复原版共享记录、来源选择和可见开关。启动不复制或切换姨姨表。此前创建的 personal_period_* 表保留,但本版不读写。
- 照片响应保持原始 uploads/... 路径,不签名、不改成 /backend/uploads/...;上传静态文件恢复原访问方式,兼容正式版小程序。
- 抽烟使用独立的 smoking_wallets 和 smoking_entries;消耗一支扣一支、买烟和别人递烟加库存、撤销操作保持事务和幂等。库存随账号保留。
- 72 小时解绑冷静期继续保留:双方同意后开始计时,期间可取消,到期解除绑定且保留记录。其他数据权限按原版运行。
## 部署 | 项目 | 当前生产值 |
| --- | --- |
| 系统 / 架构 | Ubuntu 24,Linux amd64 |
| 服务器 | `103.231.12.59` |
| 域名 | `https://we.sundynix.cn` |
| API | `https://we.sundynix.cn/api` |
| 管理端 | `https://we.sundynix.cn/backend/admin/login` |
| 部署目录 | `/home/workspace` |
| systemd 服务 | `past-go.service` |
| 后端监听 | `127.0.0.1:8081`,由 Nginx 代理 |
| PostgreSQL | 服务器本机 `127.0.0.1:5432`,数据库 `past-go` |
| Redis | 服务器本机,具体认证、DB 和前缀以线上配置为准 |
| 文件存储 | MinIO,连接和 bucket 以线上配置为准 |
| Nginx 站点配置目录 | `/www/server/panel/vhost/nginx/` |
1. 在宝塔备份当前数据库和 uploads,保存原可运行 exe;停止现有 Go 服务。 服务器目录:
2. 仅替换 past-server.exe,保留现有 config.yaml、data_dir、uploads、data 和数据库。无需重新配置 Nginx,已正常工作的 uploads 转发继续使用。
3. 启动时补齐抽烟表和冷静期字段;仅迁移旧抽烟库存与流水。smoking_records_v1=done 后不会重复;已经完成 account_records_v1 的环境也不会重新导入库存。不存在姨姨数据迁移。
4. 先验证正式版日记、相册、照片、记忆日、歌曲、普通打卡和姨姨记录,再用新版小程序验证抽烟。
5. 新版小程序的姨姨页面已同步恢复共享方式;抽烟需要上传新版小程序才能显示。
Go 全套和前端回归在本机执行;尚未在生产数据库或微信真机运行本版。历史 binlog 和检查报告显示原记录在库,不应通过清表或倒放日志部署本版。 ```text
/home/workspace/
├── past-server # Linux 可执行文件,内含管理端静态资源
├── config.yaml # 生产配置,不在 Git 中
├── cert/ # 微信支付私钥、公钥及可选商户证书
├── log/YYYY-MM-DD/ # zap 按日期、级别生成的日志
└── backups/ # 每次更新的旧程序、配置、数据库备份与发布包
```
以下为 20261005 历史说明,当前部署以以上步骤为准。 管理端无需单独部署到 Nginx 网站根目录。上传图片保存在 MinIO,不能通过清理服务器本地文件夹来清理用户文件。
--- ## 2. 首次拉取与本地开发
# 20261005 UI 精修与驼峰 API 更新 本地需要 Git、Go(以 `past-go/go.mod` 的版本为准)、make、Node.js 和 npm。当前管理端 Vite 要求 Node `^20.19.0 || >=22.12.0`;实际依赖以锁文件和安装时提示为准。
## 部署顺序 从仓库根目录执行:
1. 停止现有服务,备份原程序;用新 Windows AMD64 `past-server.exe` 替换程序。保留当前 `config.yaml`、`uploads`、`data` 和数据库。示例配置仅供参考,不要覆盖现有配置。 ```bash
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。 cd past-go
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。 cp config.dev.example.yaml config.dev.yaml
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。 # 编辑 config.dev.yaml,补齐自己的开发配置,再继续:
5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。 make web
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。 make dev
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。 ```
## 接口规则 首次拉取没有 `web/admin/dist`,必须先执行 `make web`,否则 Go 的 `go:embed` 编译会报资源不存在。`make dev` 构建本机程序并使用 `config.dev.yaml` 启动,默认监听 `:8080`。
- 请求地址:`/api/接口名`,例如 `/api/periodList`、`/api/diaryList?page=1&pageSize=20`。 本地管理端:`http://127.0.0.1:8080/backend/admin/login`。
- 小程序、管理后台、上传统一使用新路径;不再附加 GET `_t`。
- 例:`POST /api/periodUpdate`,JSON `{ "id": "314", "cycleLength": 25, "periodLength": 5, "startDate": "2026-09-27", "endDate": "2026-10-01" }`。
- 路径、查询参数、JSON 请求与响应字段统一 lowerCamelCase,例如 `cycleLength`、`periodVisible`、`canAccess`。Multipart 表单字段同样为驼峰。字段值、请求方法和鉴权延续原业务约定。
- 后端及两个客户端在网络边界转换字段名,内部数据库和页面数据模型保持既有结构。旧 PHP 入口与带下划线的旧路径继续兼容。API 响应 `Cache-Control: no-store`;后台请求使用 `cache: no-store`。
- Bearer、上传 token 与后台同源 Cookie 仍有效。上传继续携带兼容 Android 的 token/action 表单项。
- 原 PHP 风格入口继续兼容已发布旧小程序。媒体与后台静态地址仍在 `/backend/` 下。
## 小程序验证 开发配置需要填写:
- Android 与 iPhone:标题与微信胶囊不重叠,首屏和底部内容不被遮挡。 - PostgreSQL:地址、端口、数据库、账号密码。启动会检查并创建目标库、执行 GORM AutoMigrate;账号需要对应权限。
- 首页:未绑定显示注册天数和邀请入口,绑定后显示在一起天数;解绑后首页、我的立即按接口更新。 - Redis:地址、认证、DB、`key_prefix`。Redis 是启动依赖,连接失败时后端不会启动。
- 三个底部入口:首页、记录、我的;详情返回与发布、编辑流程正常。 - MinIO:endpoint、access/secret key、bucket、SSL 配置;文件通过 `/api/files/:fileId` 访问。
- 日记和相册:首次加载、空列表、网络失败分别展示;创建相册与上传照片正常。 - 微信:AppID、AppSecret、订阅消息模板 ID;AppSecret 只放后端。
- 姨姨:对方可见单独操作;关闭自己的开关不能把自己锁住,权限由 API 判断。双方都有绑定前数据时仍选择唯一记录。 - `tencent_map_key`:用于地址解析等地图功能,沿用负责人提供的配置。
- 改周期、持续天数、开始和结束日期保存后,重新进入展示服务端最新值。 - 微信支付:需要联调支付时填写商户配置和证书路径,否则在开发配置中关闭 `wechat_pay.enabled`。
本地已检查 WXML/WXSS 编译、JS 语法、请求/上传 URL 与导航尺寸、Go 测试与管理后台构建;未替代真实微信设备和线上数据库联调。 模板当前指向团队内网 PostgreSQL,不能将其误当成本机空库。团队需明确开发库和 bucket 的归属;使用独立环境时同时调整数据库、MinIO bucket、Redis DB 或前缀。不要把开发测试账号或配置导入生产环境。
## 本次 UI 精修 开发前后可执行:
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。 ```bash
# 当前目录为仓库内的 past-go/
go test ./...
go vet ./...
node --test ../weapp/tests/*.test.cjs
```
真实 PostgreSQL / MinIO 测试是额外集成检查,配置方式见 [后端 README](../README.md)。普通 `go test ./...` 未设置集成环境变量时会跳过这部分测试。
## 3. 生产配置与首次准备
现有服务器更新直接进入第 4 节,不要重新初始化数据库或覆盖配置。
新服务器需准备 PostgreSQL、Redis、Nginx,以及可用的 MinIO bucket。由负责人提供真实生产 `config.yaml` 和支付证书;Git 中只有示例,不能直接用示例覆盖线上文件。
关键配置:
```yaml
listen: "127.0.0.1:8081"
data_dir: "/home/workspace"
debug: false
```
其他节点按环境填写 `db`、`redis`、`minio`、`wechat`、`wechat_pay` 和地图 key。生产 Redis 使用独立前缀,例如 `past-go:prod:`。
当前 AppID 为 `wxb463820bf36dd5d6`。操作通知和打卡提醒共用模板:
```yaml
wechat:
# appid、secret 使用负责人提供的真实值
notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU"
```
模板字段为 `thing1`、`time2`、`thing3`;若更换 AppID 或模板,需要同时更新后端配置和 `weapp/utils/subscribe.js`,并在对应小程序后台确认模板存在。
微信支付回调应配置为:
```text
https://we.sundynix.cn/api/wechatPayNotify
```
支付证书路径相对于配置文件解析。商户私钥和微信支付公钥需要与配置匹配,`certificate_path` 是可选的商户证书校验配置。
systemd 模板在仓库根目录的 [`deploy/linux/past.service`](../../deploy/linux/past.service)。首次部署时复制到 `/etc/systemd/system/past-go.service`,执行:
```bash
systemctl daemon-reload
systemctl enable past-go
```
服务使用 `www:www` 运行:程序权限 `750 root:www`,配置建议 `600 www:www`;`cert/` 及其私钥必须可被 `www` 读取,运行目录必须允许 `www` 创建日志和临时文件。程序上传并完成配置后,再执行 `systemctl start past-go`。
新数据库会初始化管理员 `past / past`;已有数据库正常启动不会重置管理员密码。
## 4. 本地生成 Linux 发布包
以下命令从仓库根目录开始。默认目标是当前生产 Linux amd64;不要将 macOS 本机构建的程序上传到 Linux。
```bash
cd past-go
past_version="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
# 重新构建管理端并嵌入后端,避免后台静态资源仍是旧版。
make all GOOS=linux GOARCH=amd64 VERSION="$past_version"
go test ./...
go vet ./...
node --test ../weapp/tests/*.test.cjs
past_package="dist/releases/$past_version"
mkdir -p "$past_package"
cp dist/past-server-linux-amd64 "$past_package/past-server"
printf '%s\n' "$past_version" > "$past_package/VERSION"
(cd "$past_package" && shasum -a 256 past-server > SHA256SUMS)
tar -czf dist/past-release.tar.gz -C "$past_package" \
past-server VERSION SHA256SUMS
```
产物:`past-go/dist/past-release.tar.gz`。建议提交代码后打包,使版本号中的提交 ID 可以追溯;发布时记录是否含未提交改动。
只修改后端或管理端,需要更新服务器二进制;只修改小程序页面,则重新编译、上传小程序即可。涉及接口变更时,先部署兼容的新后端,再上传小程序。
## 5. 宝塔上传与更新现有服务器
在宝塔「文件」进入 `/home/workspace`,上传上一节生成的 `past-release.tar.gz`。先确认上传成功,再进入宝塔「终端」。
下面的命令**仅适用于第 4 节生成的发布包格式**,不要直接用于历史 `past-server.new + manifest.json` 发布包。数据库参数应与服务器现有 `config.yaml` 一致;命令会交互询问数据库密码,不会改动线上配置、证书或已有业务数据。
在宝塔终端整段粘贴执行:
```bash
bash <<'DEPLOY'
set -euo pipefail
umask 077
cd /home/workspace
# 根据现有 config.yaml 核对这四项。
past_db_host=127.0.0.1
past_db_port=5432
past_db_name=past-go
past_db_user=root
test -f past-release.tar.gz
test -f past-server
test -f config.yaml
mkdir -p backups
past_backup=$(mktemp -d /home/workspace/backups/release-XXXXXX)
mkdir "$past_backup/release"
cp -a past-server "$past_backup/past-server.before"
cp -a config.yaml "$past_backup/config.before.yaml"
read -r -s -p 'PostgreSQL password: ' past_db_password < /dev/tty
printf '\n'
PGPASSWORD="$past_db_password" pg_dump \
-h "$past_db_host" -p "$past_db_port" -U "$past_db_user" \
-d "$past_db_name" -Fc --no-owner --no-acl \
-f "$past_backup/database.before.dump"
unset past_db_password
tar -xzf past-release.tar.gz -C "$past_backup/release" \
past-server VERSION SHA256SUMS
(cd "$past_backup/release" && sha256sum -c SHA256SUMS)
past_version=$(cat "$past_backup/release/VERSION")
install -o root -g www -m 750 \
"$past_backup/release/past-server" ./past-server.next
past_switched=0
rollback() {
past_status=$?
if [ "$past_switched" = 1 ]; then
echo '部署失败,恢复原程序'
systemctl stop past-go || true
cp -a "$past_backup/past-server.before" ./past-server.next
mv -f ./past-server.next ./past-server
systemctl start past-go || true
fi
exit "$past_status"
}
trap rollback EXIT
past_switched=1
systemctl stop past-go
mv -f ./past-server.next ./past-server
systemctl start past-go
past_ready=0
for attempt in $(seq 1 40); do
if curl -fsS --max-time 2 http://127.0.0.1:8081/healthz | grep -qx 'ok'; then
past_ready=1
break
fi
sleep 1
done
test "$past_ready" = 1
./past-server -version | grep -Fx "past-server $past_version"
systemctl is-active --quiet past-go
past_switched=0
mv past-release.tar.gz "$past_backup/"
echo "部署成功:$past_version"
echo "备份目录:$past_backup"
systemctl is-active past-go
DEPLOY
```
出现 `部署成功` 和 `active` 才表示本机启动检查通过。启动等待期间短暂出现 `curl: (7)` 是轮询结果;若最终失败,应检查错误和服务日志,不能只凭文件已上传就判断部署完成。
该命令备份 PostgreSQL 的一致性快照,并在更新失败时恢复旧程序。**不会自动回滚数据库迁移**;涉及不兼容表结构变更的版本,需单独制定数据库迁移、停写和恢复方案。不要在上线失败后直接覆盖或恢复正在写入的生产数据库。
## 6. 上线验证与排查
```bash
/home/workspace/past-server -version
systemctl status past-go --no-pager
curl -fsS http://127.0.0.1:8081/healthz
curl -fsS https://we.sundynix.cn/healthz
curl -I https://we.sundynix.cn/
journalctl -u past-go -n 100 --no-pager
```
核对以下功能:
- 公网 `/healthz` 返回 `ok`,根路径跳转管理端登录页。
- 管理员可登录,公告、存储套餐和文件列表能正常加载。
- 已绑定用户的记录功能正常;未绑定用户点击记录、更多、存储入口会提示邀请绑定,后端记录接口返回绑定权限错误。
- 图片能访问;稳定文件地址 `/api/files/:fileId` 保留缓存与条件请求能力。
- 打卡页能读取账号保存的抽烟显示开关,位置在「我的 → 更多」。
- 涉及支付改动时,用负责人指定的测试套餐和真实微信用户验证支付回调、主动查单与权益到账;不要依靠前端支付成功提示判断入账。
启动失败优先检查 PostgreSQL、Redis、MinIO、支付证书路径与文件权限。业务日志在 `/home/workspace/log/YYYY-MM-DD/`;`journalctl` 可看到服务启动阶段的错误。
Nginx 只有在域名、监听端口或代理规则变化时才需调整。通过宝塔「文件」编辑 `/www/server/panel/vhost/nginx/` 内实际站点配置,保留 SSL 和证书续签配置,不在网站表单中覆盖整份配置。代理规则参考 [`deploy/linux/nginx-past.conf`](../../deploy/linux/nginx-past.conf)。修改后执行:
```bash
/www/server/nginx/sbin/nginx -t
# 只有上一条检查成功后,才执行:
/www/server/nginx/sbin/nginx -s reload
```
## 7. 手动回滚程序
选择本次更新输出的真实备份目录。确认本版数据库仍兼容旧程序后,在宝塔终端执行:
```bash
past_backup='/home/workspace/backups/替换为实际备份目录'
test -f "$past_backup/past-server.before" && \
install -o root -g www -m 750 \
"$past_backup/past-server.before" /home/workspace/past-server.rollback && \
systemctl stop past-go && \
mv -f /home/workspace/past-server.rollback /home/workspace/past-server && \
systemctl start past-go
systemctl status past-go --no-pager
curl -fsS http://127.0.0.1:8081/healthz
```
备份中的 `config.before.yaml` 和 `database.before.dump` 留作必要时人工恢复,不在程序回滚时默认覆盖配置或数据库。
## 8. 小程序发布
微信开发者工具打开仓库内 `weapp/`。`weapp/config/env.js` 仅导出 `API_BASE`,不会自动区分开发版、体验版和正式版:
```js
// 本地开发或真机联调:填写开发机地址。
const API_BASE = 'http://192.168.100.3:8080/api';
module.exports = { API_BASE };
```
上传体验版或正式版前,将这一行改为:
```js
const API_BASE = 'https://we.sundynix.cn/api';
```
重新编译,确认小程序 AppID、接口地址和隐私协议配置,真机验证后再上传。在微信后台配置正式 request / uploadFile / downloadFile 合法域名 `https://we.sundynix.cn`;若某功能直接访问其他域名的对象或外部图片,需要同时核对该实际域名的配置。
每次发布同时记录后端版本、小程序版本和验证结果,避免协同开发时把本地测试地址随代码上传到正式版。
+110 -2
View File
@@ -1,6 +1,42 @@
-- 只读排查:在原业务数据库中执行。不要修改关系、记录归属或迁移标记。 -- 只读排查:在 PostgreSQL past-go 数据库中执行。不要修改关系、记录归属或迁移标记。
SELECT DATABASE() AS current_database; SELECT CURRENT_DATABASE() AS current_database;
WITH unbinding_file_cleanup AS (SELECT * FROM "past_unbinding_file_cleanup" WHERE deleted_at IS NULL),
users AS (SELECT * FROM "past_user" WHERE deleted_at IS NULL),
couples AS (SELECT * FROM "past_couple" WHERE deleted_at IS NULL),
invitations AS (SELECT * FROM "past_invitation" WHERE deleted_at IS NULL),
unbind_requests AS (SELECT * FROM "past_unbind_request" WHERE deleted_at IS NULL),
invite_links AS (SELECT * FROM "past_invite_link" WHERE deleted_at IS NULL),
timeline AS (SELECT * FROM "past_timeline" WHERE deleted_at IS NULL),
albums AS (SELECT * FROM "past_album" WHERE deleted_at IS NULL),
photos AS (SELECT * FROM "past_photo" WHERE deleted_at IS NULL),
diaries AS (SELECT * FROM "past_diary" WHERE deleted_at IS NULL),
songs AS (SELECT * FROM "past_song" WHERE deleted_at IS NULL),
checkins AS (SELECT * FROM "past_checkin" WHERE deleted_at IS NULL),
checkin_logs AS (SELECT * FROM "past_checkin_log" WHERE deleted_at IS NULL),
smoking_accounts AS (SELECT * FROM "past_smoking_account" WHERE deleted_at IS NULL),
smoking_logs AS (SELECT * FROM "past_smoking_log" WHERE deleted_at IS NULL),
period_records AS (SELECT * FROM "past_period_record" WHERE deleted_at IS NULL),
period_daily_records AS (SELECT * FROM "past_period_daily_record" WHERE deleted_at IS NULL),
period_sex_records AS (SELECT * FROM "past_period_sex_record" WHERE deleted_at IS NULL),
settings AS (SELECT * FROM "past_setting" WHERE deleted_at IS NULL),
diary_comments AS (SELECT * FROM "past_diary_comment" WHERE deleted_at IS NULL),
subscribe_templates AS (SELECT * FROM "past_subscribe_template" WHERE deleted_at IS NULL),
activity_log AS (SELECT * FROM "past_activity_log" WHERE deleted_at IS NULL),
admins AS (SELECT * FROM "past_admin" WHERE deleted_at IS NULL),
user_tokens AS (SELECT * FROM "past_user_token" WHERE deleted_at IS NULL),
subscribe_message_logs AS (SELECT * FROM "past_subscribe_message_log" WHERE deleted_at IS NULL),
notify_log AS (SELECT * FROM "past_notify_log" WHERE deleted_at IS NULL),
pets AS (SELECT * FROM "past_pet" WHERE deleted_at IS NULL),
pet_dailies AS (SELECT * FROM "past_pet_daily" WHERE deleted_at IS NULL),
pet_achievements AS (SELECT * FROM "past_pet_achievement" WHERE deleted_at IS NULL),
pet_items AS (SELECT * FROM "past_pet_item" WHERE deleted_at IS NULL),
pet_dynamics AS (SELECT * FROM "past_pet_dynamic" WHERE deleted_at IS NULL),
storage_orders AS (SELECT * FROM "past_storage_order" WHERE deleted_at IS NULL),
diary_likes AS (SELECT * FROM "past_diary_like" WHERE deleted_at IS NULL),
diary_favorites AS (SELECT * FROM "past_diary_favorite" WHERE deleted_at IS NULL),
smoking_wallets AS (SELECT * FROM "past_smoking_wallet" WHERE deleted_at IS NULL),
smoking_entries AS (SELECT * FROM "past_smoking_entry" WHERE deleted_at IS NULL)
SELECT 'users' AS table_name, COUNT(*) AS records FROM users SELECT 'users' AS table_name, COUNT(*) AS records FROM users
UNION ALL SELECT 'couples', COUNT(*) FROM couples UNION ALL SELECT 'couples', COUNT(*) FROM couples
UNION ALL SELECT 'diaries', COUNT(*) FROM diaries UNION ALL SELECT 'diaries', COUNT(*) FROM diaries
@@ -12,6 +48,42 @@ UNION ALL SELECT 'songs', COUNT(*) FROM songs
UNION ALL SELECT 'period_records', COUNT(*) FROM period_records; UNION ALL SELECT 'period_records', COUNT(*) FROM period_records;
-- 关系一致性:只显示内部 ID,不输出手机号、密码、token 等信息。 -- 关系一致性:只显示内部 ID,不输出手机号、密码、token 等信息。
WITH unbinding_file_cleanup AS (SELECT * FROM "past_unbinding_file_cleanup" WHERE deleted_at IS NULL),
users AS (SELECT * FROM "past_user" WHERE deleted_at IS NULL),
couples AS (SELECT * FROM "past_couple" WHERE deleted_at IS NULL),
invitations AS (SELECT * FROM "past_invitation" WHERE deleted_at IS NULL),
unbind_requests AS (SELECT * FROM "past_unbind_request" WHERE deleted_at IS NULL),
invite_links AS (SELECT * FROM "past_invite_link" WHERE deleted_at IS NULL),
timeline AS (SELECT * FROM "past_timeline" WHERE deleted_at IS NULL),
albums AS (SELECT * FROM "past_album" WHERE deleted_at IS NULL),
photos AS (SELECT * FROM "past_photo" WHERE deleted_at IS NULL),
diaries AS (SELECT * FROM "past_diary" WHERE deleted_at IS NULL),
songs AS (SELECT * FROM "past_song" WHERE deleted_at IS NULL),
checkins AS (SELECT * FROM "past_checkin" WHERE deleted_at IS NULL),
checkin_logs AS (SELECT * FROM "past_checkin_log" WHERE deleted_at IS NULL),
smoking_accounts AS (SELECT * FROM "past_smoking_account" WHERE deleted_at IS NULL),
smoking_logs AS (SELECT * FROM "past_smoking_log" WHERE deleted_at IS NULL),
period_records AS (SELECT * FROM "past_period_record" WHERE deleted_at IS NULL),
period_daily_records AS (SELECT * FROM "past_period_daily_record" WHERE deleted_at IS NULL),
period_sex_records AS (SELECT * FROM "past_period_sex_record" WHERE deleted_at IS NULL),
settings AS (SELECT * FROM "past_setting" WHERE deleted_at IS NULL),
diary_comments AS (SELECT * FROM "past_diary_comment" WHERE deleted_at IS NULL),
subscribe_templates AS (SELECT * FROM "past_subscribe_template" WHERE deleted_at IS NULL),
activity_log AS (SELECT * FROM "past_activity_log" WHERE deleted_at IS NULL),
admins AS (SELECT * FROM "past_admin" WHERE deleted_at IS NULL),
user_tokens AS (SELECT * FROM "past_user_token" WHERE deleted_at IS NULL),
subscribe_message_logs AS (SELECT * FROM "past_subscribe_message_log" WHERE deleted_at IS NULL),
notify_log AS (SELECT * FROM "past_notify_log" WHERE deleted_at IS NULL),
pets AS (SELECT * FROM "past_pet" WHERE deleted_at IS NULL),
pet_dailies AS (SELECT * FROM "past_pet_daily" WHERE deleted_at IS NULL),
pet_achievements AS (SELECT * FROM "past_pet_achievement" WHERE deleted_at IS NULL),
pet_items AS (SELECT * FROM "past_pet_item" WHERE deleted_at IS NULL),
pet_dynamics AS (SELECT * FROM "past_pet_dynamic" WHERE deleted_at IS NULL),
storage_orders AS (SELECT * FROM "past_storage_order" WHERE deleted_at IS NULL),
diary_likes AS (SELECT * FROM "past_diary_like" WHERE deleted_at IS NULL),
diary_favorites AS (SELECT * FROM "past_diary_favorite" WHERE deleted_at IS NULL),
smoking_wallets AS (SELECT * FROM "past_smoking_wallet" WHERE deleted_at IS NULL),
smoking_entries AS (SELECT * FROM "past_smoking_entry" WHERE deleted_at IS NULL)
SELECT c.id AS couple_id, c.status, c.user1_id, c.user2_id, SELECT c.id AS couple_id, c.status, c.user1_id, c.user2_id,
u1.couple_id AS user1_current_couple, u2.couple_id AS user2_current_couple u1.couple_id AS user1_current_couple, u2.couple_id AS user2_current_couple
FROM couples c FROM couples c
@@ -20,6 +92,42 @@ LEFT JOIN users u2 ON u2.id = c.user2_id
ORDER BY c.id; ORDER BY c.id;
-- 按历史空间检查记录是否还在,以及新的作者过滤是否排除了旧记录。 -- 按历史空间检查记录是否还在,以及新的作者过滤是否排除了旧记录。
WITH unbinding_file_cleanup AS (SELECT * FROM "past_unbinding_file_cleanup" WHERE deleted_at IS NULL),
users AS (SELECT * FROM "past_user" WHERE deleted_at IS NULL),
couples AS (SELECT * FROM "past_couple" WHERE deleted_at IS NULL),
invitations AS (SELECT * FROM "past_invitation" WHERE deleted_at IS NULL),
unbind_requests AS (SELECT * FROM "past_unbind_request" WHERE deleted_at IS NULL),
invite_links AS (SELECT * FROM "past_invite_link" WHERE deleted_at IS NULL),
timeline AS (SELECT * FROM "past_timeline" WHERE deleted_at IS NULL),
albums AS (SELECT * FROM "past_album" WHERE deleted_at IS NULL),
photos AS (SELECT * FROM "past_photo" WHERE deleted_at IS NULL),
diaries AS (SELECT * FROM "past_diary" WHERE deleted_at IS NULL),
songs AS (SELECT * FROM "past_song" WHERE deleted_at IS NULL),
checkins AS (SELECT * FROM "past_checkin" WHERE deleted_at IS NULL),
checkin_logs AS (SELECT * FROM "past_checkin_log" WHERE deleted_at IS NULL),
smoking_accounts AS (SELECT * FROM "past_smoking_account" WHERE deleted_at IS NULL),
smoking_logs AS (SELECT * FROM "past_smoking_log" WHERE deleted_at IS NULL),
period_records AS (SELECT * FROM "past_period_record" WHERE deleted_at IS NULL),
period_daily_records AS (SELECT * FROM "past_period_daily_record" WHERE deleted_at IS NULL),
period_sex_records AS (SELECT * FROM "past_period_sex_record" WHERE deleted_at IS NULL),
settings AS (SELECT * FROM "past_setting" WHERE deleted_at IS NULL),
diary_comments AS (SELECT * FROM "past_diary_comment" WHERE deleted_at IS NULL),
subscribe_templates AS (SELECT * FROM "past_subscribe_template" WHERE deleted_at IS NULL),
activity_log AS (SELECT * FROM "past_activity_log" WHERE deleted_at IS NULL),
admins AS (SELECT * FROM "past_admin" WHERE deleted_at IS NULL),
user_tokens AS (SELECT * FROM "past_user_token" WHERE deleted_at IS NULL),
subscribe_message_logs AS (SELECT * FROM "past_subscribe_message_log" WHERE deleted_at IS NULL),
notify_log AS (SELECT * FROM "past_notify_log" WHERE deleted_at IS NULL),
pets AS (SELECT * FROM "past_pet" WHERE deleted_at IS NULL),
pet_dailies AS (SELECT * FROM "past_pet_daily" WHERE deleted_at IS NULL),
pet_achievements AS (SELECT * FROM "past_pet_achievement" WHERE deleted_at IS NULL),
pet_items AS (SELECT * FROM "past_pet_item" WHERE deleted_at IS NULL),
pet_dynamics AS (SELECT * FROM "past_pet_dynamic" WHERE deleted_at IS NULL),
storage_orders AS (SELECT * FROM "past_storage_order" WHERE deleted_at IS NULL),
diary_likes AS (SELECT * FROM "past_diary_like" WHERE deleted_at IS NULL),
diary_favorites AS (SELECT * FROM "past_diary_favorite" WHERE deleted_at IS NULL),
smoking_wallets AS (SELECT * FROM "past_smoking_wallet" WHERE deleted_at IS NULL),
smoking_entries AS (SELECT * FROM "past_smoking_entry" WHERE deleted_at IS NULL)
SELECT 'diaries' AS table_name, d.couple_id, c.status, SELECT 'diaries' AS table_name, d.couple_id, c.status,
COUNT(*) AS records, COUNT(*) AS records,
SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author
+5 -1
View File
@@ -1,5 +1,9 @@
# 放在 past.sundynix.cn 现有 HTTPS server 块里。 # 放在 we.sundynix.cn 现有 HTTPS server 块里。
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。 # proxy_pass 末尾不要加 /,需要保留 /api/接口名。
location = / {
return 302 /backend/admin/login;
}
location = /api { location = /api {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
+28 -1
View File
@@ -1,3 +1,30 @@
# PostgreSQL / GORM 数据层(当前版本)
数据库为 `192.168.100.127:5432/past-go`,账号 `root`;连接配置见 `config.example.yaml`。
复制为本地 `config.yaml` 并填写微信配置。启动时先连接 `postgres` 管理库检查并创建目标库,再执行 GORM AutoMigrate。
也可以只运行迁移:
```sh
go run ./cmd/migrate -config config.yaml
```
模型定义在 `internal/model/models.go`,所有模型嵌入 `BaseModel`:`Id string`、`CreatedAt`、`UpdatedAt`、`DeletedAt`。
主键与关联 ID 使用 PostgreSQL `uuid` 类型;主键默认 `gen_random_uuid()`,不自增。
表名统一为 `past_` + 单数 snake_case(例如 `User` → `past_user`、`DiaryComment` → `past_diary_comment`);字段按 `UserId` → `user_id` 命名。
启动时会将旧 `past-` 前缀的表无损重命名为 `past_`,保留 UUID、数据和索引。
新增、修改、删除通过 GORM 模型执行;复杂报表通过 GORM Raw 执行,并统一过滤所有关联表及子查询中的 `deleted_at`。
删除保留原行及附件;活跃记录的唯一索引带 `WHERE deleted_at IS NULL`,删除后可以重新创建同业务键的记录。
用户对外的六位展示编号是独立业务字段,继续使用数据库序列分配;它不是 UUID 主键。情侣空间编号为 `CP` + 不带连字符的情侣 UUID。
本次创建的是新的 PostgreSQL 库,不会导入旧 MySQL 数据。历史备份如需迁入,需要单独完成数据转换和旧 ID 到 UUID 的关联映射。
验证:`go test ./...`、`go vet ./...`;在设置 `PAST_TEST_POSTGRES_DSN` 后,可运行 `go test ./internal/store -run TestPostgresIntegration -v` 验证真实 PostgreSQL(测试数据全部回滚)。
以下保留历史说明,数据库配置与部署步骤以以上内容为准。
---
# Windows (amd64) 部署说明 # Windows (amd64) 部署说明
后端 Go 服务通过 `//go:embed` 把 React 管理后台(`web/admin/dist`)一并打进 `past-server.exe`, 后端 Go 服务通过 `//go:embed` 把 React 管理后台(`web/admin/dist`)一并打进 `past-server.exe`,
@@ -17,7 +44,7 @@ windows/
## 部署步骤 ## 部署步骤
1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。 1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。
2. **填写配置**:编辑 `config.yaml`(数据库、微信、虚拟支付等)。 2. **填写配置**:编辑 `config.yaml`(数据库、微信、微信支付 API v3 等)。
- 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。 - 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。
- **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`, - **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`,
`../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。 `../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。
+36 -7
View File
@@ -3,19 +3,29 @@ module past
go 1.26.4 go 1.26.4
require ( require (
github.com/alicebob/miniredis/v2 v2.40.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.10.1 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/minio/minio-go/v7 v7.4.1
github.com/redis/go-redis/v9 v9.23.0
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -23,20 +33,39 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.12.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.60.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
) )
+93 -19
View File
@@ -1,16 +1,26 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -25,8 +35,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -34,68 +42,134 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+26
View File
@@ -0,0 +1,26 @@
// Package cache initializes the shared Redis connection.
package cache
import (
"context"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
"past/internal/config"
)
func Open(cfg config.RedisConfig) (*redis.Client, error) {
if strings.TrimSpace(cfg.Addr) == "" || strings.TrimSpace(cfg.KeyPrefix) == "" || cfg.DB < 0 {
return nil, fmt.Errorf("Redis addr、key_prefix 不能为空,db 必须非负")
}
client := redis.NewClient(&redis.Options{Addr: cfg.Addr, Username: cfg.Username, Password: cfg.Password, DB: cfg.DB, DialTimeout: 3 * time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, MaxRetries: 1, ContextTimeoutEnabled: true})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("Redis 连接失败: %w", err)
}
return client, nil
}
+95 -29
View File
@@ -3,26 +3,81 @@ package config
import ( import (
"fmt" "fmt"
"net"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type RedisConfig struct {
Addr string `yaml:"addr"`
Username string `yaml:"username"`
Password string `yaml:"password"`
DB int `yaml:"db"`
KeyPrefix string `yaml:"key_prefix"`
}
type LogConfig struct {
Level string `yaml:"level"`
Console bool `yaml:"console"`
RetentionDays int `yaml:"retention_days"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
}
type MinioConfig struct {
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"use_ssl"`
}
type WechatPayConfig struct {
Enabled bool `yaml:"enabled"`
AppID string `yaml:"appid"`
MchID string `yaml:"mch_id"`
CertificateSerial string `yaml:"certificate_serial"`
CertificatePath string `yaml:"certificate_path"`
APIv3Key string `yaml:"api_v3_key"`
PrivateKeyPath string `yaml:"private_key_path"`
PublicKeyID string `yaml:"public_key_id"`
PublicKeyPath string `yaml:"public_key_path"`
NotifyURL string `yaml:"notify_url"`
}
func (c WechatPayConfig) ValidateNotifyURL() error {
u, err := url.Parse(c.NotifyURL)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("微信支付 notify_url 必须为公网 HTTPS 地址,且不含查询参数")
}
return nil
}
type Config struct { type Config struct {
Redis RedisConfig `yaml:"redis"`
Log LogConfig `yaml:"log"`
WechatPay WechatPayConfig `yaml:"wechat_pay"`
Minio MinioConfig `yaml:"minio"`
Listen string `yaml:"listen"` // 监听地址,如 ":8080" Listen string `yaml:"listen"` // 监听地址,如 ":8080"
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等) Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
// DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。 // DataDir 运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储在 MinIO。
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。 // 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
DataDir string `yaml:"data_dir"` DataDir string `yaml:"data_dir"`
DB struct { DB struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
Name string `yaml:"name"` Name string `yaml:"name"`
User string `yaml:"user"` User string `yaml:"user"`
Pass string `yaml:"pass"` Pass string `yaml:"pass"`
SSLMode string `yaml:"sslmode"`
} `yaml:"db"` } `yaml:"db"`
Wechat struct { Wechat struct {
@@ -42,31 +97,23 @@ type Config struct {
// Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭 // Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭
NotifyIntervalMin int `yaml:"notify_interval_min"` NotifyIntervalMin int `yaml:"notify_interval_min"`
// VirtualPay 微信小程序虚拟支付(米大师虚拟支付2.0)配置
VirtualPay struct {
AppID string `yaml:"appid"` // 小程序 AppID(默认复用 wechat.appid)
OfferID string `yaml:"offer_id"` // 支付应用 ID
AppKey string `yaml:"app_key"` // 正式环境 AppKey
SandboxKey string `yaml:"sandbox_key"` // 沙箱环境 AppKey
Env int `yaml:"env"` // 0-正式环境 1-沙箱环境
NotifyToken string `yaml:"notify_token"` // 支付结果回调 Token(微信后台消息推送配置)
NotifyAESKey string `yaml:"notify_aes_key"` // 支付结果回调 EncodingAESKey(可选,用于解密加密回调)
} `yaml:"virtual_pay"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
c := &Config{} c := &Config{}
c.Redis = RedisConfig{Addr: "127.0.0.1:6379", KeyPrefix: "past-go:"}
c.Log = LogConfig{Level: "info", Console: true, RetentionDays: 30, MaxSizeMB: 100, MaxBackups: 10}
c.Listen = ":8080" c.Listen = ":8080"
c.DB.Host = "127.0.0.1" c.DB.Host = "192.168.100.127"
c.DB.Port = 3306 c.DB.Port = 5432
c.DB.Name = "past" c.DB.Name = "past-go"
c.DB.User = "past" c.DB.User = "root"
c.DB.Pass = "root"
c.DB.SSLMode = "disable"
c.MaxUploadMB = 50 c.MaxUploadMB = 50
c.NotifyIntervalMin = 5 c.NotifyIntervalMin = 5
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8" c.Wechat.NotifyTemplateID = "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU"
c.PHPCompat = "8.0" c.PHPCompat = "8.0"
c.VirtualPay.Env = 1
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -75,6 +122,18 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(b, c); err != nil { if err := yaml.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err) return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
} }
if c.WechatPay.AppID == "" {
c.WechatPay.AppID = c.Wechat.AppID
}
if c.WechatPay.Enabled && c.WechatPay.AppID != c.Wechat.AppID {
return nil, fmt.Errorf("微信支付 AppID 必须与微信登录 AppID 一致")
}
// Certificate paths are relative to the selected YAML file, independent of cwd.
for _, p := range []*string{&c.WechatPay.PrivateKeyPath, &c.WechatPay.PublicKeyPath, &c.WechatPay.CertificatePath} {
if *p != "" && !filepath.IsAbs(*p) {
*p = filepath.Join(filepath.Dir(path), *p)
}
}
if c.DataDir == "" { if c.DataDir == "" {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
@@ -91,12 +150,19 @@ func Load(path string) (*Config, error) {
} }
func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") } func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") }
func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "data") } func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "log") }
// DSN uses URL escaping for database names, passwords and IPv6 hosts.
func (c *Config) DSN() string { func (c *Config) DSN() string {
// interpolateParams=true:统一走文本协议,所有列以字节返回,由 store 按列类型转换(对齐 PDO 行为) u := &url.URL{Scheme: "postgres", User: url.UserPassword(c.DB.User, c.DB.Pass), Host: net.JoinHostPort(c.DB.Host, strconv.Itoa(c.DB.Port)), Path: "/" + c.DB.Name}
// time_zone 会在驱动建立的每个 MySQL 连接上设置会话时区,保证 SQL NOW()/CURRENT_TIMESTAMP q := url.Values{}
// 与 Go 进程的 Asia/Shanghai 一致。使用固定 +08:00 不依赖 MySQL 时区表是否安装。 mode := c.DB.SSLMode
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=false&interpolateParams=true&loc=Local&time_zone=%%27%%2B08%%3A00%%27&multiStatements=false", if mode == "" {
c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name) mode = "disable"
}
q.Set("sslmode", mode)
q.Set("TimeZone", "Asia/Shanghai")
q.Set("connect_timeout", "10")
u.RawQuery = strings.ReplaceAll(q.Encode(), "%2F", "/")
return u.String()
} }
+52 -16
View File
@@ -1,27 +1,63 @@
package config package config
import ( import (
"strings" "github.com/jackc/pgx/v5"
"os"
"path/filepath"
"testing" "testing"
"github.com/go-sql-driver/mysql"
) )
func TestDSNSetsShanghaiOffsetForEveryConnection(t *testing.T) { func TestPostgresDSN(t *testing.T) {
c := &Config{} c := &Config{}
c.DB.Host = "127.0.0.1" c.DB.Host = "192.168.100.127"
c.DB.Port = 3306 c.DB.Port = 5432
c.DB.Name = "past" c.DB.Name = "past-go"
c.DB.User = "past" c.DB.User = "root"
got := c.DSN() c.DB.Pass = "a' @:/?&\\密码"
if !strings.Contains(got, "time_zone=%27%2B08%3A00%27") { c.DB.SSLMode = "disable"
t.Fatalf("DSN does not set MySQL session time zone: %s", got) parsed, err := pgx.ParseConfig(c.DSN())
}
parsed, err := mysql.ParseDSN(got)
if err != nil { if err != nil {
t.Fatalf("invalid DSN: %v", err) t.Fatal(err)
} }
if parsed.Params["time_zone"] != "'+08:00'" { if parsed.Database != "past-go" || parsed.Host != c.DB.Host || parsed.Port != 5432 || parsed.Password != c.DB.Pass || parsed.RuntimeParams["TimeZone"] != "Asia/Shanghai" {
t.Fatalf("unexpected decoded MySQL time_zone: %q", parsed.Params["time_zone"]) t.Fatalf("PostgreSQL DSN did not round-trip connection settings")
}
}
func TestDevExampleConfig(t *testing.T) {
c, err := Load("../../config.dev.example.yaml")
if err != nil {
t.Fatal(err)
}
if c.Wechat.AppID != "wxb463820bf36dd5d6" || c.Wechat.Secret != "" {
t.Fatal("dev template must use the new AppID and keep AppSecret local")
}
if c.Listen != ":8080" || !c.Debug || c.NotifyIntervalMin != 0 || c.DB.Name != "past-go" || c.Minio.Bucket != "past-go" {
t.Fatal("dev service configuration is incomplete")
}
if c.Wechat.NotifyTemplateID != "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" {
t.Fatal("dev configuration must use the current app subscription template")
}
}
func TestWechatPayPathsFollowConfigurationDirectory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
data := []byte("wechat:\n appid: wx-test\nwechat_pay:\n enabled: true\n private_key_path: cert/key.pem\n public_key_path: cert/pub.pem\n")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.WechatPay.AppID != "wx-test" || cfg.WechatPay.PrivateKeyPath != filepath.Join(dir, "cert/key.pem") || cfg.WechatPay.PublicKeyPath != filepath.Join(dir, "cert/pub.pem") || cfg.WechatPay.CertificatePath != "" {
t.Fatal("incorrect payment configuration path or default AppID")
}
if err := os.WriteFile(path, append(data, []byte(" appid: different-app\n")...), 0600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("payment AppID mismatching login accepted")
} }
} }
+58
View File
@@ -0,0 +1,58 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"past/internal/httpapi"
"past/internal/store"
"time"
)
func deletionKey(c *httpapi.Ctx, token string) string {
h := sha256.Sum256([]byte(token))
return c.App.Wechat.KeyPrefix + "account-delete:" + c.UID() + ":" + hex.EncodeToString(h[:])
}
func accountDeletionPreview(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
s, err := c.DB.AccountDeletionSummary(c.UID())
if errors.Is(err, store.ErrAccountBound) {
return c.Err(409, err.Error())
}
if err != nil {
return err
}
token := httpapi.NewToken()
ctx, cancel := context.WithTimeout(c.R.Context(), 3*time.Second)
defer cancel()
if err := c.App.Wechat.Redis.Set(ctx, deletionKey(c, token), s.Revision, 5*time.Minute).Err(); err != nil {
return err
}
return c.OK(M{"summary": s, "confirmation_token": token, "expires_in": 300})
}
func accountDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if c.In("confirmed") != true || c.InStr("confirmation_text") != "注销并删除全部数据" || c.InStr("confirmation_token") == "" {
return c.Err(400, "请完成二次确认")
}
ctx, cancel := context.WithTimeout(c.R.Context(), 5*time.Second)
defer cancel()
revision, err := c.App.Wechat.Redis.Get(ctx, deletionKey(c, c.InStr("confirmation_token"))).Result()
if err != nil || revision == "" {
return c.Err(409, "确认已过期,请重新查看注销数据统计")
}
err = c.DB.DeleteAccount(c.UID(), revision)
if errors.Is(err, store.ErrAccountBound) || errors.Is(err, store.ErrAccountChanged) {
return c.Err(409, err.Error())
}
if err != nil {
return err
}
c.App.Wechat.Redis.Del(ctx, deletionKey(c, c.InStr("confirmation_token")))
return c.OK(M{"success": true, "message": "账号及内容已删除,独占的存储文件正在清理"})
}
+62 -57
View File
@@ -11,6 +11,7 @@ import (
) )
func registerAdmin(r *httpapi.Router) { func registerAdmin(r *httpapi.Router) {
r.Handle("admin_files", adminFiles)
r.Handle("admin_login", adminLogin) r.Handle("admin_login", adminLogin)
r.Handle("admin_check_auth", adminCheckAuth) r.Handle("admin_check_auth", adminCheckAuth)
r.Handle("admin_logout", adminLogout) r.Handle("admin_logout", adminLogout)
@@ -53,7 +54,7 @@ func adminLogin(c *httpapi.Ctx) error {
return err return err
} }
if admin != nil && verifyPassword(c.InStr("password"), admin.Str("password")) { if admin != nil && verifyPassword(c.InStr("password"), admin.Str("password")) {
c.AdminLogin(admin.Int("id"), admin.Str("username")) c.AdminLogin(admin.Str("id"), admin.Str("username"))
return c.OK(M{"success": true, "username": admin["username"]}) return c.OK(M{"success": true, "username": admin["username"]})
} }
return c.Err(401, "用户名或密码错误") return c.Err(401, "用户名或密码错误")
@@ -95,7 +96,7 @@ func adminStatsDetail(c *httpapi.Ctx) error {
} }
month := date[:7] month := date[:7]
dateCount := func(table string) int64 { dateCount := func(table string) int64 {
return countC(c, "SELECT COUNT(*) as c FROM `"+table+"` WHERE DATE(created_at) = ?", date) return countC(c, "SELECT COUNT(*) as c FROM "+table+" WHERE DATE(created_at) = ?", date)
} }
contents := M{} contents := M{}
var total int64 var total int64
@@ -107,13 +108,14 @@ func adminStatsDetail(c *httpapi.Ctx) error {
return c.OK(M{ return c.OK(M{
"date": date, "date": date,
"today_registers": dateCount("users"), "today_registers": dateCount("users"),
"month_registers": countC(c, "SELECT COUNT(*) as c FROM users WHERE DATE_FORMAT(created_at, '%Y-%m') = ?", month), "month_registers": countC(c, "SELECT COUNT(*) as c FROM users WHERE TO_CHAR(created_at, 'YYYY-MM') = ?", month),
"date_registers": dateCount("users"), "date_registers": dateCount("users"),
"date_couples": dateCount("couples"), "date_couples": dateCount("couples"),
"date_contents": contents, "date_contents": contents,
"date_contents_total": total, "date_contents_total": total,
"total_users": tableCount(c, "users"), "total_users": tableCount(c, "users"),
"total_couples": tableCount(c, "couples"), "total_couples": tableCount(c, "couples"),
"total_storage_used": allStorageUsedBytes(),
}) })
} }
@@ -143,7 +145,7 @@ func adminCouples(c *httpapi.Ctx) error {
} }
// petFullDetail 宠物详情各子表(admin_couple_detail / admin_pet_detail 共用) // petFullDetail 宠物详情各子表(admin_couple_detail / admin_pet_detail 共用)
func petFullDetail(c *httpapi.Ctx, cid int64) (dailies, achievements, dynamics, items []store.Row, err error) { func petFullDetail(c *httpapi.Ctx, cid string) (dailies, achievements, dynamics, items []store.Row, err error) {
if dailies, err = c.DB.Query("SELECT * FROM pet_dailies WHERE couple_id = ? ORDER BY date DESC LIMIT 60", cid); err != nil { if dailies, err = c.DB.Query("SELECT * FROM pet_dailies WHERE couple_id = ? ORDER BY date DESC LIMIT 60", cid); err != nil {
return return
} }
@@ -166,13 +168,13 @@ func rowOrFalse(r store.Row) any {
} }
func adminCoupleDetail(c *httpapi.Ctx) error { func adminCoupleDetail(c *httpapi.Ctx) error {
var cid int64 var cid string
if c.Method == "GET" { if c.Method == "GET" {
cid = c.QInt("couple_id") cid = c.Q("couple_id")
} else { } else {
cid = c.InInt("couple_id") cid = c.InTrim("couple_id")
} }
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
data := M{} data := M{}
@@ -241,8 +243,8 @@ func adminCoupleDetail(c *httpapi.Ctx) error {
} }
func adminPetDetail(c *httpapi.Ctx) error { func adminPetDetail(c *httpapi.Ctx) error {
cid := c.QInt("couple_id") cid := c.Q("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid) pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
@@ -302,9 +304,12 @@ func adminUsers(c *httpapi.Ctx) error {
} }
if search != "" { if search != "" {
like := "%" + search + "%" like := "%" + search + "%"
if isNumeric(search) { if store.ValidUUID(search) {
where = append(where, "(u.id = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)") where = append(where, "(u.id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, store.ToInt(search), store.ToInt(search), like, like) params = append(params, search, like, like)
} else if isNumeric(search) {
where = append(where, "(u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, search, like, like)
} else { } else {
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)") where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, like, like) params = append(params, like, like)
@@ -325,7 +330,7 @@ func adminUsers(c *httpapi.Ctx) error {
pg := paged(c, rs) pg := paged(c, rs)
items := pg["items"].([]store.Row) items := pg["items"].([]store.Row)
for _, it := range items { for _, it := range items {
uid, cid := it.Int("id"), it.Int("couple_id") uid, cid := it.Str("id"), it.Str("couple_id")
it["used_bytes"] = storageUsedBytes(c, uid, cid) it["used_bytes"] = storageUsedBytes(c, uid, cid)
it["storage_vip"] = isStorageVip(c, uid) it["storage_vip"] = isStorageVip(c, uid)
it["storage_vip_expire_at"] = storageVipExpireAt(c, uid) it["storage_vip_expire_at"] = storageVipExpireAt(c, uid)
@@ -344,7 +349,7 @@ func adminFixLastLogin(c *httpapi.Ctx) error {
} }
func adminCleanLocations(c *httpapi.Ctx) error { func adminCleanLocations(c *httpapi.Ctx) error {
res, err := c.DB.Exec(`UPDATE users SET location = '' WHERE location REGEXP '^-?[0-9]+(\\.[0-9]+)?,-?[0-9]+(\\.[0-9]+)?$'`) res, err := c.DB.Exec(`UPDATE users SET location = '' WHERE location ~ '^-?[0-9]+([.][0-9]+)?,-?[0-9]+([.][0-9]+)?$'`)
if err != nil { if err != nil {
return err return err
} }
@@ -353,8 +358,8 @@ func adminCleanLocations(c *httpapi.Ctx) error {
} }
func adminUserDetail(c *httpapi.Ctx) error { func adminUserDetail(c *httpapi.Ctx) error {
uid := c.QInt("user_id") uid := c.Q("user_id")
if uid == 0 { if uid == "" {
return c.Err(400, "缺少用户ID") return c.Err(400, "缺少用户ID")
} }
user, err := c.DB.QueryOne("SELECT id, user_id, openid, username, nickname, avatar, gender, age, birthday, couple_id, created_at, last_login_at, region, location, last_login_location FROM users WHERE id = ?", uid) user, err := c.DB.QueryOne("SELECT id, user_id, openid, username, nickname, avatar, gender, age, birthday, couple_id, created_at, last_login_at, region, location, last_login_location FROM users WHERE id = ?", uid)
@@ -364,8 +369,8 @@ func adminUserDetail(c *httpapi.Ctx) error {
if user == nil { if user == nil {
return c.Err(404, "用户不存在") return c.Err(404, "用户不存在")
} }
if user.Int("couple_id") != 0 { if user.Str("couple_id") != "" {
partner, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, location, last_login_location FROM users WHERE couple_id = ? AND id != ?", user.Int("couple_id"), uid) partner, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, location, last_login_location FROM users WHERE couple_id = ? AND id != ?", user.Str("couple_id"), uid)
user["partner"] = rowOrFalse(partner) user["partner"] = rowOrFalse(partner)
} }
logs, err := c.DB.Query("SELECT cl.*, c.title as checkin_title, c.icon as checkin_icon, u2.nickname as creator_name FROM checkin_logs cl LEFT JOIN checkins c ON cl.checkin_id = c.id LEFT JOIN users u2 ON c.created_by = u2.id WHERE cl.user_id = ? ORDER BY cl.created_at DESC LIMIT 100", uid) logs, err := c.DB.Query("SELECT cl.*, c.title as checkin_title, c.icon as checkin_icon, u2.nickname as creator_name FROM checkin_logs cl LEFT JOIN checkins c ON cl.checkin_id = c.id LEFT JOIN users u2 ON c.created_by = u2.id WHERE cl.user_id = ? ORDER BY cl.created_at DESC LIMIT 100", uid)
@@ -378,7 +383,7 @@ func adminUserDetail(c *httpapi.Ctx) error {
return err return err
} }
for _, ck := range checkins { for _, ck := range checkins {
l, _ := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC LIMIT 30", ck.Int("id")) l, _ := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC LIMIT 30", ck.Str("id"))
ck["logs"] = rows(l) ck["logs"] = rows(l)
} }
user["checkins"] = rows(checkins) user["checkins"] = rows(checkins)
@@ -399,8 +404,8 @@ func adminChangePassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
if len(c.InStr("new_password")) < 6 { if len(c.InStr("new_password")) < 4 {
return c.Err(400, "新密码至少6位") return c.Err(400, "新密码至少4位")
} }
sess := c.AdminSession() sess := c.AdminSession()
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID) admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
@@ -433,8 +438,8 @@ func adminUpdateUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
userID := c.InInt("user_id") userID := c.InTrim("user_id")
if userID <= 0 { if userID == "" {
return c.Err(400, "用户ID无效") return c.Err(400, "用户ID无效")
} }
var fields []string var fields []string
@@ -469,8 +474,8 @@ func adminDeleteUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
target := c.InInt("user_id") target := c.InTrim("user_id")
if target <= 0 { if target == "" {
return c.Err(400, "用户ID无效") return c.Err(400, "用户ID无效")
} }
tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target) tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target)
@@ -478,17 +483,17 @@ func adminDeleteUser(c *httpapi.Ctx) error {
return c.Err(404, "用户不存在") return c.Err(404, "用户不存在")
} }
deleteIDs := []any{target} deleteIDs := []any{target}
if cid := tu.Int("couple_id"); cid != 0 { if cid := tu.Str("couple_id"); cid != "" {
partners, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? AND id != ?", cid, target) partners, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? AND id != ?", cid, target)
for _, p := range partners { for _, p := range partners {
deleteIDs = append(deleteIDs, p.Int("id")) deleteIDs = append(deleteIDs, p.Str("id"))
} }
for _, t := range []string{"timeline", "diaries", "songs", "checkin_logs", "checkins"} { for _, t := range []string{"timeline", "diaries", "songs", "checkin_logs", "checkins"} {
c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid) c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid)
} }
albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid) albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums { for _, a := range albums {
c.DB.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id")) c.DB.Exec("DELETE FROM photos WHERE album_id = ?", a.Str("id"))
} }
c.DB.Exec("DELETE FROM albums WHERE couple_id = ?", cid) c.DB.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
c.DB.Exec("DELETE FROM couples WHERE id = ?", cid) c.DB.Exec("DELETE FROM couples WHERE id = ?", cid)
@@ -579,8 +584,8 @@ func adminForceUnbind(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid := c.InInt("couple_id") cid := c.InTrim("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid) couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
@@ -604,8 +609,8 @@ func adminRestoreCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid := c.InInt("couple_id") cid := c.InTrim("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid) couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid)
@@ -619,8 +624,8 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid := c.InInt("couple_id") cid := c.InTrim("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid) couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid)
@@ -630,7 +635,7 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error {
if couple.Int("data_deleted") == 1 { if couple.Int("data_deleted") == 1 {
return c.OK(M{"success": true, "msg": "共同数据已删除,解绑记录保留"}) return c.OK(M{"success": true, "msg": "共同数据已删除,解绑记录保留"})
} }
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id")) users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Str("user1_id"), couple.Str("user2_id"))
c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid) c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid)
executeUnbind(c.DB, cid) executeUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"}) return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"})
@@ -641,8 +646,8 @@ func adminDeleteContent(c *httpapi.Ctx) error {
return err return err
} }
typ := c.InStr("type") typ := c.InStr("type")
id, coupleID := c.InInt("id"), c.InInt("couple_id") id, coupleID := c.InTrim("id"), c.InTrim("couple_id")
if id == 0 || coupleID == 0 { if id == "" || coupleID == "" {
return c.Err(400, "参数不完整") return c.Err(400, "参数不完整")
} }
switch typ { switch typ {
@@ -655,7 +660,6 @@ func adminDeleteContent(c *httpapi.Ctx) error {
c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID) c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID)
case "photo": case "photo":
if p, _ := c.DB.QueryOne("SELECT path FROM photos WHERE id = ?", id); p != nil { if p, _ := c.DB.QueryOne("SELECT path FROM photos WHERE id = ?", id); p != nil {
up.Remove(p.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id) c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
} }
case "song": case "song":
@@ -674,16 +678,16 @@ func adminContents(c *httpapi.Ctx) error {
if !c.QHas("type") { if !c.QHas("type") {
typ = "diary" typ = "diary"
} }
coupleID, userID := c.QInt("couple_id"), c.QInt("user_id") coupleID, userID := c.Q("couple_id"), c.Q("user_id")
startDate, endDate := c.Q("start_date"), c.Q("end_date") startDate, endDate := c.Q("start_date"), c.Q("end_date")
page, pageSize, offset := pageParams(c, 10, 50) page, pageSize, offset := pageParams(c, 10, 50)
var where []string var where []string
var params []any var params []any
if coupleID > 0 { if coupleID != "" {
where = append(where, "t.couple_id = ?") where = append(where, "t.couple_id = ?")
params = append(params, coupleID) params = append(params, coupleID)
} }
if userID > 0 { if userID != "" {
where = append(where, "t.created_by = ?") where = append(where, "t.created_by = ?")
params = append(params, userID) params = append(params, userID)
} }
@@ -699,11 +703,11 @@ func adminContents(c *httpapi.Ctx) error {
if len(where) > 0 { if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ") whereClause = "WHERE " + strings.Join(where, " AND ")
} }
coupleList, err := c.DB.Query("SELECT c.id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.id DESC") coupleList, err := c.DB.Query("SELECT c.id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.created_at DESC, c.id DESC")
if err != nil { if err != nil {
return err return err
} }
userList, err := c.DB.Query("SELECT id, user_id, nickname, username FROM users ORDER BY id DESC") userList, err := c.DB.Query("SELECT id, user_id, nickname, username FROM users ORDER BY created_at DESC, id DESC")
if err != nil { if err != nil {
return err return err
} }
@@ -752,9 +756,9 @@ func adminResetUserPassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
userID := c.InInt("user_id") userID := c.InTrim("user_id")
pw := c.InStr("new_password") pw := c.InStr("new_password")
if userID <= 0 { if userID == "" {
return c.Err(400, "用户ID无效") return c.Err(400, "用户ID无效")
} }
if len(pw) < 6 { if len(pw) < 6 {
@@ -767,8 +771,8 @@ func adminResetUserPassword(c *httpapi.Ctx) error {
} }
func adminActivityLog(c *httpapi.Ctx) error { func adminActivityLog(c *httpapi.Ctx) error {
cid := c.QInt("couple_id") cid := c.Q("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
rs, err := c.DB.Query("SELECT al.*, u.nickname, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.couple_id = ? ORDER BY al.created_at DESC LIMIT 100", cid) rs, err := c.DB.Query("SELECT al.*, u.nickname, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.couple_id = ? ORDER BY al.created_at DESC LIMIT 100", cid)
@@ -779,11 +783,11 @@ func adminActivityLog(c *httpapi.Ctx) error {
} }
func adminGetGlobalBg(c *httpapi.Ctx) error { func adminGetGlobalBg(c *httpapi.Ctx) error {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = 'bg_global'") row, _ := c.DB.QueryOne("SELECT value, file_id FROM settings WHERE key = 'bg_global'")
if row == nil { if row == nil {
return c.OK(M{"bg_image": nil}) return c.OK(M{"bg_image": nil})
} }
return c.OK(M{"bg_image": row["value"]}) return c.OK(M{"bg_image": row["value"], "bg_image_file_id": row["file_id"]})
} }
func adminSetGlobalBg(c *httpapi.Ctx) error { func adminSetGlobalBg(c *httpapi.Ctx) error {
@@ -791,25 +795,26 @@ func adminSetGlobalBg(c *httpapi.Ctx) error {
if fh == nil { if fh == nil {
return c.Err(400, "没有上传文件") return c.Err(400, "没有上传文件")
} }
res := up.Handle(fh, "", "global") res := up.HandleFor(fh, "")
if res.Error != "" { if res.Error != "" {
return c.Err(400, res.Error) return c.Err(400, res.Error)
} }
if _, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES ('bg_global', ?) ON DUPLICATE KEY UPDATE `value` = ?, updated_at = CURRENT_TIMESTAMP", res.Path, res.Path); err != nil { if _, err := c.DB.Exec("INSERT INTO settings (key, value) VALUES ('bg_global', ?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP", res.FileId, res.FileId); err != nil {
return err return err
} }
c.UploadResult = &res
return c.OK(M{"success": true, "path": res.Path}) return c.OK(M{"success": true, "path": res.Path})
} }
func adminClearGlobalBg(c *httpapi.Ctx) error { func adminClearGlobalBg(c *httpapi.Ctx) error {
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = 'bg_global'"); err != nil { if _, err := c.DB.Exec("DELETE FROM settings WHERE key = 'bg_global'"); err != nil {
return err return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
} }
func adminCoupleBgList(c *httpapi.Ctx) error { func adminCoupleBgList(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT c.id as couple_id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name, CONCAT(u1.nickname, ' & ', u2.nickname) as couple_name, s1.value as bg_image FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN settings s1 ON s1.`key` = CONCAT('bg_', c.id) ORDER BY c.id DESC") rs, err := c.DB.Query("SELECT c.id as couple_id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name, CONCAT(u1.nickname, ' & ', u2.nickname) as couple_name, s1.value as bg_image FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN settings s1 ON s1.key = CONCAT('bg_', c.id) ORDER BY c.created_at DESC, c.id DESC")
if err != nil { if err != nil {
return err return err
} }
@@ -817,11 +822,11 @@ func adminCoupleBgList(c *httpapi.Ctx) error {
} }
func adminClearCoupleBg(c *httpapi.Ctx) error { func adminClearCoupleBg(c *httpapi.Ctx) error {
cid := c.InInt("couple_id") cid := c.InTrim("couple_id")
if cid == 0 { if cid == "" {
return c.Err(400, "缺少情侣ID") return c.Err(400, "缺少情侣ID")
} }
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", fmt.Sprintf("bg_%d", cid), fmt.Sprintf("bgver_%d", cid)); err != nil { if _, err := c.DB.Exec("DELETE FROM settings WHERE key = ? OR key = ?", fmt.Sprintf("bg_%s", cid), fmt.Sprintf("bgver_%s", cid)); err != nil {
return err return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"math"
"strings"
"time"
"gorm.io/gorm"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
)
func adminFiles(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil {
return err
}
db := c.DB.ORM
query := db.Model(&model.File{})
if suffix := strings.ToLower(strings.TrimSpace(c.Q("suffix"))); suffix != "" {
query = query.Where("suffix=?", suffix)
}
if owner := strings.TrimSpace(c.Q("user_id")); owner != "" {
if !store.ValidUUID(owner) {
return c.Err(400, "上传用户 ID 无效")
}
query = query.Where("id IN (?)", db.Model(&model.FileUpload{}).Select("file_id").Where("user_id=?", owner))
}
if keyword := strings.TrimSpace(c.Q("keyword")); keyword != "" {
like := "%" + keyword + "%"
matchingUsers := db.Model(&model.User{}).Unscoped().Select("id").Where("nickname ILIKE ? OR user_id LIKE ? OR username ILIKE ?", like, like, like)
if store.ValidUUID(keyword) {
matchingUsers = matchingUsers.Or("id=?", keyword)
}
uploads := db.Model(&model.FileUpload{}).Select("file_id").Where("user_id IN (?)", matchingUsers)
if store.ValidUUID(keyword) {
query = query.Where("id=? OR md5 ILIKE ? OR original_name ILIKE ? OR object_key ILIKE ? OR id IN (?)", keyword, like, like, like, uploads)
} else {
query = query.Where("md5 ILIKE ? OR original_name ILIKE ? OR object_key ILIKE ? OR id IN (?)", like, like, like, uploads)
}
}
var from, to time.Time
for _, key := range []string{"start_date", "end_date"} {
if value := c.Q(key); value != "" {
date, err := time.Parse("2006-01-02", value)
if err != nil {
return c.Err(400, "日期格式错误")
}
if key == "start_date" {
from = date
query = query.Where("created_at>=?", value)
} else {
to = date
query = query.Where("created_at<?", date.AddDate(0, 0, 1).Format("2006-01-02"))
}
}
}
if !from.IsZero() && !to.IsZero() && from.After(to) {
return c.Err(400, "开始日期不能晚于结束日期")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return err
}
page, size, offset := pageParams(c, 10, 100)
var files []model.File
if err := query.Order("created_at DESC,id DESC").Limit(int(size)).Offset(int(offset)).Find(&files).Error; err != nil {
return err
}
ids := make([]string, 0, len(files))
for _, file := range files {
ids = append(ids, file.Id)
}
var uploads []model.FileUpload
if len(ids) > 0 {
if err := db.Where("file_id IN ?", ids).Preload("User", func(tx *gorm.DB) *gorm.DB { return tx.Unscoped() }).Order("created_at,id").Find(&uploads).Error; err != nil {
return err
}
}
owners := map[string][]M{}
for _, record := range uploads {
user := record.User
owners[record.FileId] = append(owners[record.FileId], M{"id": record.UserId, "display_id": user.UserId, "nickname": user.Nickname, "deleted": user.DeletedAt.Valid})
}
items := make([]M, 0, len(files))
for _, file := range files {
users := owners[file.Id]
if users == nil {
users = []M{}
}
items = append(items, M{"id": file.Id, "size": file.Size, "suffix": file.Suffix, "content_type": file.ContentType, "object_key": file.ObjectKey, "md5": file.Md5, "url": file.Url, "access_url": store.FileAccessPath(file.Id), "bucket": file.Bucket, "original_name": file.OriginalName, "created_at": file.CreatedAt.Format("2006-01-02 15:04:05"), "uploaders": users})
}
return c.OK(M{"items": items, "total": total, "page": page, "page_size": size, "total_pages": int64(math.Ceil(float64(total) / float64(size)))})
}
+1 -1
View File
@@ -55,7 +55,7 @@ func adminSubscribeLogs(c *httpapi.Ctx) error {
return err return err
} }
page, size, offset := pageParams(c, 10, 100) page, size, offset := pageParams(c, 10, 100)
items, err := c.DB.Query("SELECT l.*, recipient.nickname AS recipient_name, recipient.user_id AS recipient_uid, sender.nickname AS sender_name"+base+" ORDER BY l.id DESC LIMIT ? OFFSET ?", append(args, size, offset)...) items, err := c.DB.Query("SELECT l.*, recipient.nickname AS recipient_name, recipient.user_id AS recipient_uid, sender.nickname AS sender_name"+base+" ORDER BY l.created_at DESC, l.id DESC LIMIT ? OFFSET ?", append(args, size, offset)...)
if err != nil { if err != nil {
return err return err
} }
@@ -17,7 +17,7 @@ func TestAdminSubscribeLogsFilteredServerPagination(t *testing.T) {
} }
return nil return nil
}, Columns: []string{"total"}, Rows: [][]driver.Value{{int64(21)}}}, }, Columns: []string{"total"}, Rows: [][]driver.Value{{int64(21)}}},
{Match: "ORDER BY l.id DESC LIMIT ? OFFSET ?", Args: append(filters, int64(10), int64(10)), Columns: []string{"id", "to_openid", "status", "error_msg"}, Rows: [][]driver.Value{{int64(11), "recipient-openid", "failed", "用户未订阅"}}}, {Match: "ORDER BY l.created_at DESC, l.id DESC LIMIT ? OFFSET ?", Args: append(filters, int64(10), int64(10)), Columns: []string{"id", "to_openid", "status", "error_msg"}, Rows: [][]driver.Value{{int64(11), "recipient-openid", "failed", "用户未订阅"}}},
}) })
adminSubscribeLogs(c) adminSubscribeLogs(c)
for _, want := range []string{`"total":21`, `"total_pages":3`, `"page":2`, `recipient-openid`, `用户未订阅`} { for _, want := range []string{`"total":21`, `"total_pages":3`, `"page":2`, `recipient-openid`, `用户未订阅`} {
+86 -83
View File
@@ -26,7 +26,7 @@ func registerAlbum(r *httpapi.Router) {
} }
// coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ? // coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ?
func coupleSpaceID(c *httpapi.Ctx, cid int64) string { func coupleSpaceID(c *httpapi.Ctx, cid string) string {
row, _ := c.DB.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid) row, _ := c.DB.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if row == nil { if row == nil {
return "" return ""
@@ -34,23 +34,31 @@ func coupleSpaceID(c *httpapi.Ctx, cid int64) string {
return row.Str("couple_space_id") return row.Str("couple_space_id")
} }
func albumList(c *httpapi.Ctx) error { // Album access follows the creator: personal before binding, shared with the current partner afterwards.
ids := coupleUserIds(c, 0) func albumUserIds(c *httpapi.Ctx) []string {
if len(ids) == 0 { if ids := coupleUserIds(c, ""); len(ids) == 2 {
photoTotal := int64(0) return ids
for _, album := range demoData("album") {
photoTotal += store.ToInt(album["photo_count"])
}
return demoListResponse(c, "album", M{"photo_total": photoTotal})
} }
return []string{c.UID(), c.UID()}
}
func albumActivity(c *httpapi.Ctx, kind, description string) string {
if c.CoupleID() == "" {
return ""
}
return logActivity(c.DB, c.CoupleID(), c.UID(), kind, description, "")
}
func albumList(c *httpapi.Ctx) error {
ids := albumUserIds(c)
suffix, window := listWindow(c) suffix, window := listWindow(c)
items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...) items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
if err != nil { if err != nil {
return err return err
} }
for _, it := range items { for _, it := range items {
if store.Truthy(it["latest_photo"]) { if !store.Truthy(it["cover"]) && store.Truthy(it["latest_photo"]) {
it["cover"] = it["latest_photo"] it["cover"] = it["latest_photo"]
it["cover_file_id"] = store.FileIdentifier(store.ToStr(it["latest_photo"]))
} }
} }
total := countC(c, "SELECT COUNT(*) as c FROM albums WHERE created_by IN (?, ?)", ids[0], ids[1]) total := countC(c, "SELECT COUNT(*) as c FROM albums WHERE created_by IN (?, ?)", ids[0], ids[1])
@@ -62,11 +70,12 @@ func albumAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid, err := requireCoupleID(c) cid := c.CoupleID()
if err != nil {
return err
}
uid := c.UID() uid := c.UID()
var albumCouple any
if cid != "" {
albumCouple = cid
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err return err
} }
@@ -76,13 +85,13 @@ func albumAdd(c *httpapi.Ctx) error {
location = c.App.Geo.ReverseGeocode(lat, lng) location = c.App.Geo.ReverseGeocode(lat, lng)
} }
id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid) albumCouple, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid)
if err != nil { if err != nil {
return err return err
} }
title := c.InStr("title") title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "") actID := albumActivity(c, "album_create", "创建了相册「"+title+"」")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "新相册「" + mbTruncate(title, 20) + "」", "thing1": "新相册「" + mbTruncate(title, 20) + "」",
"thing2": "TA创建了新相册,快来一起看看吧 📸", "thing2": "TA创建了新相册,快来一起看看吧 📸",
@@ -97,10 +106,8 @@ func albumUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err return err
} }
@@ -130,10 +137,7 @@ func albumDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil {
return err
}
id := c.In("id") id := c.In("id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil { if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil {
return c.Err(404, "相册不存在") return c.Err(404, "相册不存在")
@@ -151,8 +155,8 @@ func setAlbumCover(c *httpapi.Ctx) error {
if c.Method != "POST" { if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
} }
ids := coupleUserIds(c, 0) ids := albumUserIds(c)
albumID := store.ToInt(c.Form("album_id")) albumID := c.Form("album_id")
var a store.Row var a store.Row
if len(ids) == 2 { if len(ids) == 2 {
a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1]) a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1])
@@ -160,21 +164,21 @@ func setAlbumCover(c *httpapi.Ctx) error {
if a == nil { if a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound) return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
} }
spaceID := coupleSpaceID(c, c.CoupleID())
fh := c.File("cover") fh := c.File("cover")
if fh == nil { if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
} }
if err := CheckStorageQuota(c, fh.Size); err != nil { res := up.HandleFor(fh, c.UID())
return err
}
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" { if res.Path != "" {
c.UploadResult = &res
if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil { if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil {
return err return err
} }
return c.OK(M{"success": true, "path": res.Path}) return c.OK(M{"success": true, "path": res.Path})
} }
if res.Code == upload.ErrStorageQuotaExceeded {
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
}
if strings.Contains(res.Error, "违规") { if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed) return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
} }
@@ -193,7 +197,7 @@ func photos(c *httpapi.Ctx) error {
if albumID == "" { if albumID == "" {
albumID = "0" albumID = "0"
} }
ids := coupleUserIds(c, 0) ids := albumUserIds(c)
if len(ids) == 0 { if len(ids) == 0 {
return listResponse(c, nil, 0, nil) return listResponse(c, nil, 0, nil)
} }
@@ -217,19 +221,14 @@ func photoUpload(c *httpapi.Ctx) error {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
} }
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
} }
cid := c.CoupleID()
if cid == 0 {
return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound)
}
spaceID := coupleSpaceID(c, cid)
albumID := c.Form("album_id") albumID := c.Form("album_id")
if albumID == "" { if albumID == "" {
albumID = "0" albumID = "0"
} }
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil { if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, albumUserIds(c)[0], albumUserIds(c)[1]); a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound) return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
} }
description := c.Form("description") description := c.Form("description")
@@ -237,45 +236,51 @@ func photoUpload(c *httpapi.Ctx) error {
return err return err
} }
uploaded := []string{} uploaded := []string{}
quotaExceeded := false
errors := []string{} errors := []string{}
files := c.Files("photos") files := c.Files("photos")
if len(files) == 0 { if len(files) == 0 {
files = c.Files("file") files = c.Files("file")
} }
// 存储配额检查(照片是存储大头,超限引导付费) // 存储配额检查(照片是存储大头,超限引导付费)
var pendingBytes int64
if len(files) > 0 {
for _, fh := range files {
pendingBytes += fh.Size
}
} else {
pendingBytes = int64(len(c.Body()))
}
if err := CheckStorageQuota(c, pendingBytes); err != nil {
return err
}
if len(files) == 0 { if len(files) == 0 {
// 兼容某些安卓端直接传二进制:从原始请求体读取 // 兼容某些安卓端直接传二进制:从原始请求体读取
raw := c.Body() raw := c.Body()
if len(raw) > 100 { if len(raw) > 100 {
res := up.SaveRaw(raw, "", "jpg", "", upload.CouplePath(spaceID, "photos")) res := up.SaveRawFor(raw, "jpg", c.UID())
if res.Code == upload.ErrStorageQuotaExceeded {
quotaExceeded = true
}
if res.Path != "" { if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, created_by) VALUES (?, ?, ?, ?)", albumID, res.Path, description, uid) c.UploadResult = &res
uploaded = append(uploaded, res.Path) if _, err := c.DB.Exec("INSERT INTO photos (album_id,path,description,created_by) VALUES (?,?,?,?)", albumID, res.Path, description, uid); err != nil {
setFirstCover(c, albumID, res.Path) errors = append(errors, err.Error())
} else if strings.Contains(res.Error, "违规") { } else {
errors = append(errors, "所发布内容含违规信息") uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path)
}
} else {
errors = append(errors, res.Error)
} }
} }
if len(uploaded) == 0 { if len(uploaded) == 0 {
if quotaExceeded {
return c.ErrCode(403, "存储空间不足,请扩容后重试", ErrStorageQuotaExceeded)
}
return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived) return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived)
} }
} }
for _, fh := range files { for _, fh := range files {
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos")) res := up.HandleFor(fh, c.UID())
if res.Code == upload.ErrStorageQuotaExceeded {
quotaExceeded = true
}
if res.Path != "" { if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, taken_at, location, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", c.UploadResult = &res
albumID, res.Path, description, c.Form("taken_at"), c.Form("location"), c.Form("activity"), c.Form("batch_id"), uid) if _, err := c.DB.Exec("INSERT INTO photos (album_id,path,description,taken_at,location,activity,batch_id,created_by) VALUES (?,?,?,?,?,?,?,?)", albumID, res.Path, description, c.Form("taken_at"), c.Form("location"), c.Form("activity"), c.Form("batch_id"), uid); err != nil {
errors = append(errors, err.Error())
continue
}
uploaded = append(uploaded, res.Path) uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path) setFirstCover(c, albumID, res.Path)
} else { } else {
@@ -283,11 +288,14 @@ func photoUpload(c *httpapi.Ctx) error {
} }
} }
if len(uploaded) == 0 && len(errors) > 0 { if len(uploaded) == 0 && len(errors) > 0 {
if quotaExceeded {
return c.ErrCode(403, "存储空间不足,请扩容后重试", ErrStorageQuotaExceeded)
}
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed) return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
} }
if len(uploaded) > 0 { if len(uploaded) > 0 {
actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "") actID := albumActivity(c, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)))
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新", "thing1": "相册有更新",
"thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)), "thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)),
@@ -296,25 +304,26 @@ func photoUpload(c *httpapi.Ctx) error {
}, "pages/albums/albums", actID, uid) }, "pages/albums/albums", actID, uid)
} }
} }
return c.OK(M{"success": true, "uploaded": len(uploaded), "errors": errors}) ids := make([]string, 0, len(uploaded))
for _, path := range uploaded {
ids = append(ids, store.FileIdentifier(path))
}
return c.OK(M{"success": true, "uploaded": len(uploaded), "paths": uploaded, "file_ids": ids, "errors": errors})
} }
func photoDelete(c *httpapi.Ctx) error { func photoDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err
}
id := c.In("id") id := c.In("id")
photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1]) photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil { if err != nil {
return err return err
} }
if photo != nil { if photo != nil {
albumID := photo.Int("album_id") albumID := photo.Str("album_id")
up.Remove(photo.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id) c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID) album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID)
if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") { if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") {
@@ -333,15 +342,11 @@ func photoAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid, err := requireCoupleID(c)
if err != nil {
return err
}
if !c.InBool("album_id") || !c.InBool("path") { if !c.InBool("album_id") || !c.InBool("path") {
return c.Err(400, "参数不完整") return c.Err(400, "参数不完整")
} }
albumID := c.In("album_id") albumID := c.In("album_id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil { if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, albumUserIds(c)[0], albumUserIds(c)[1]); a == nil {
return c.Err(404, "相册不存在") return c.Err(404, "相册不存在")
} }
uid := c.UID() uid := c.UID()
@@ -356,8 +361,8 @@ func photoAdd(c *httpapi.Ctx) error {
return err return err
} }
setFirstCover(c, albumID, c.InStr("path")) setFirstCover(c, albumID, c.InStr("path"))
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "") actID := albumActivity(c, "photo_upload", "上传了照片")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新", "thing1": "相册有更新",
"thing2": "TA上传了新照片 📸", "thing2": "TA上传了新照片 📸",
@@ -373,12 +378,10 @@ func photoUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err id := c.InTrim("id")
} if id == "" || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
id := c.InInt("id")
if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
return c.Err(400, "参数不完整") return c.Err(400, "参数不完整")
} }
location, lat, lng := photoLocation(c) location, lat, lng := photoLocation(c)
@@ -396,7 +399,7 @@ func photoUpdate(c *httpapi.Ctx) error {
if err := checkTextOr400(c, strings.TrimSpace(location+" "+activity)); err != nil { if err := checkTextOr400(c, strings.TrimSpace(location+" "+activity)); err != nil {
return err return err
} }
_, err = c.DB.Exec("UPDATE photos p JOIN albums a ON p.album_id = a.id SET p.taken_at = ?, p.location = ?, p.activity = ?, p.lat = ?, p.lng = ? WHERE p.id = ? AND a.created_by IN (?, ?)", takenAt, location, activity, lat, lng, id, ids[0], ids[1]) _, err = c.DB.Exec("UPDATE photos SET taken_at = ?, location = ?, activity = ?, lat = ?, lng = ? WHERE id = ? AND album_id IN (SELECT id FROM albums WHERE created_by IN (?, ?))", takenAt, location, activity, lat, lng, id, ids[0], ids[1])
if err != nil { if err != nil {
return err return err
} }
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"past/internal/httpapi"
"strings"
"unicode/utf8"
)
const (
announcementOnce = "login_once"
announcementAlways = "always"
announcementOff = "off"
)
type announcementConfig struct {
Mode string `json:"mode"`
Enabled bool `json:"enabled"`
Title string `json:"title"`
Content string `json:"content"`
}
func readAnnouncement(c *httpapi.Ctx) (announcementConfig, error) {
cfg := announcementConfig{Title: "公告"}
value, _ := settingValue(c, "home_announcement")
if value != "" {
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
return cfg, err
}
}
// Existing enabled-only settings become once per login, without losing their text.
if cfg.Mode == "" {
if cfg.Enabled {
cfg.Mode = announcementOnce
} else {
cfg.Mode = announcementOff
}
}
cfg.Enabled = cfg.Mode != announcementOff
return cfg, nil
}
func announcement(c *httpapi.Ctx) error {
cfg, err := readAnnouncement(c)
if err != nil {
return err
}
cfg.Enabled = cfg.Mode != announcementOff && strings.TrimSpace(cfg.Content) != ""
if !cfg.Enabled {
cfg.Content = ""
}
b, _ := json.Marshal(cfg)
sum := sha256.Sum256(b)
return c.OK(M{"mode": cfg.Mode, "enabled": cfg.Enabled, "title": cfg.Title, "content": cfg.Content, "revision": hex.EncodeToString(sum[:])})
}
func adminAnnouncement(c *httpapi.Ctx) error {
if c.Method == "GET" {
cfg, err := readAnnouncement(c)
if err != nil {
return err
}
return c.OK(cfg)
}
if err := c.RequirePOST(); err != nil {
return err
}
mode := c.InStr("mode")
if !c.InHas("mode") {
enabled, ok := c.In("enabled").(bool)
if !ok {
return c.Err(400, "请选择通知方式")
}
mode = announcementOff
if enabled {
mode = announcementOnce
}
}
if mode != announcementOnce && mode != announcementAlways && mode != announcementOff {
return c.Err(400, "通知方式无效")
}
cfg := announcementConfig{Mode: mode, Enabled: mode != announcementOff, Title: strings.TrimSpace(c.InStr("title")), Content: strings.TrimSpace(c.InStr("content"))}
if cfg.Title == "" {
cfg.Title = "公告"
}
if utf8.RuneCountInString(cfg.Title) > 60 || utf8.RuneCountInString(cfg.Content) > 3000 {
return c.Err(400, "公告内容最多3000字,标题最多60字")
}
b, _ := json.Marshal(cfg)
if err := setSetting(c, "home_announcement", string(b)); err != nil {
return err
}
return c.OK(M{"success": true})
}
@@ -0,0 +1,777 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"regexp"
"sort"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/minio/minio-go/v7"
"gorm.io/gorm/logger"
"past/internal/config"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
"past/internal/upload"
"past/internal/wechat"
)
type apiTransport func(*http.Request) (*http.Response, error)
func (f apiTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
type apiSQLLog struct {
logger.Interface
t *testing.T
}
func (l apiSQLLog) Trace(ctx context.Context, start time.Time, fc func() (string, int64), err error) {
if err != nil {
q, _ := fc()
l.t.Errorf("SQL failed: %v; %s", err, q)
}
}
// This runs the real HTTP router against an isolated PostgreSQL schema. Only
// WeChat/map HTTP dependencies are stubbed; GORM, PostgreSQL and uploads are real.
func TestPostgresAPI(t *testing.T) {
for _, stringsMode := range []bool{false, true} {
t.Run(fmt.Sprintf("numeric_strings_%v", stringsMode), func(t *testing.T) { runPostgresAPI(t, stringsMode) })
}
}
func runPostgresAPI(t *testing.T, stringsMode bool) {
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set PAST_TEST_POSTGRES_DSN")
}
root, err := store.Open(dsn)
if err != nil {
t.Fatal(err)
}
defer root.Close()
schema := fmt.Sprintf("past_api_test_%d", time.Now().UnixNano())
if err := root.ORM.Exec(`CREATE SCHEMA "` + schema + `"`).Error; err != nil {
t.Fatal(err)
}
defer func() {
if err := root.ORM.Exec(`DROP SCHEMA "` + schema + `" CASCADE`).Error; err != nil {
t.Error(err)
}
}()
u, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
params := u.Query()
params.Set("search_path", schema)
u.RawQuery = params.Encode()
u.RawQuery = strings.ReplaceAll(u.RawQuery, "%2F", "/")
db, err := store.Open(u.String())
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.InitSchema(); err != nil {
t.Fatal(err)
}
// Simulate the deployed album schema and verify AutoMigrate relaxes its old binding requirement.
if err := db.ORM.Exec(`ALTER TABLE past_album ALTER COLUMN couple_id SET NOT NULL`).Error; err != nil {
t.Fatal(err)
}
if err := db.InitSchema(); err != nil {
t.Fatal(err)
}
if err := db.MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
db.ORM.Config.Logger = apiSQLLog{Interface: logger.Default, t: t}
db.NumericAsString = stringsMode
dataDir := t.TempDir()
cfg := &config.Config{DataDir: dataDir, MaxUploadMB: 5}
cacheServer := miniredis.RunT(t)
cacheClient := redis.NewClient(&redis.Options{Addr: cacheServer.Addr()})
defer cacheClient.Close()
wx := wechat.New("test-app", "test-secret", "test-template", cacheClient, "test:", db)
oldTransport := http.DefaultTransport
http.DefaultTransport = apiTransport(func(r *http.Request) (*http.Response, error) {
body := `{"errcode":0,"access_token":"test-access","expires_in":7200,"openid":"wx-test-user","session_key":"test-session"}`
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
})
defer func() { http.DefaultTransport = oldTransport }()
undoLog := zap.ReplaceGlobals(zap.NewNop())
defer undoLog()
app := &httpapi.App{Cfg: cfg, DB: db, Wechat: wx, Geo: geo.New(""), Admin: httpapi.NewAdminSessions()}
router := httpapi.NewRouter(app, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
testConfig := os.Getenv("PAST_TEST_CONFIG")
if testConfig == "" {
testConfig = "../../config.yaml"
}
storageConfig, err := config.Load(testConfig)
if err != nil {
t.Fatal(err)
}
storageConfig.Minio.Bucket = "past-api-test-" + fmt.Sprint(time.Now().UnixNano())
uploader, err := upload.New(storageConfig.Minio, db, 5*1024*1024, wx)
if err != nil {
t.Fatal(err)
}
defer func() {
for object := range uploader.Client.ListObjects(context.Background(), uploader.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if object.Err != nil {
t.Error(object.Err)
break
}
if err := uploader.Client.RemoveObject(context.Background(), uploader.Bucket, object.Key, minio.RemoveObjectOptions{}); err != nil {
t.Error(err)
}
}
if err := uploader.Client.RemoveBucket(context.Background(), uploader.Bucket); err != nil {
t.Error(err)
}
}()
Register(router, uploader)
engine := router.Engine(false)
covered := map[string]int{}
happy := map[string]bool{}
var adminCookies []*http.Cookie
request := func(action, method, token string, body M, want int) M {
t.Helper()
var payload io.Reader
uri := "/api/" + action
if method == "GET" {
v := url.Values{}
for key, value := range body {
v.Set(key, fmt.Sprint(value))
}
uri += "?" + v.Encode()
} else {
b, _ := json.Marshal(body)
payload = bytes.NewReader(b)
}
req := httptest.NewRequest(method, uri, payload)
req.Header.Set("Content-Type", "application/json")
if token == "admin" {
for _, cookie := range adminCookies {
req.AddCookie(cookie)
}
} else if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
out := httptest.NewRecorder()
engine.ServeHTTP(out, req)
covered[action]++
if want > 0 && out.Code != want {
t.Fatalf("%s %s: status %d want %d; %s", method, action, out.Code, want, out.Body.String())
}
if out.Code >= 500 {
t.Fatalf("%s %s: %d %s", method, action, out.Code, out.Body.String())
}
if out.Code >= 200 && out.Code < 300 {
happy[action] = true
}
if action == "admin_login" && out.Code == 200 {
adminCookies = out.Result().Cookies()
}
var value any
if err := json.Unmarshal(out.Body.Bytes(), &value); err != nil {
t.Fatalf("%s invalid JSON: %s", action, out.Body.String())
}
if result, ok := value.(map[string]any); ok {
return normalizeAPIMap(M(result))
}
return M{"items": value}
}
uploadFile := func(action, tok, field string, fields M) M {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
for key, value := range fields {
mw.WriteField(key, fmt.Sprint(value))
}
part, _ := mw.CreateFormFile(field, "integration.png")
im := image.NewRGBA(image.Rect(0, 0, 64, 48))
draw.Draw(im, im.Bounds(), image.NewUniform(color.RGBA{R: 24, G: 149, B: 184, A: 255}), image.Point{}, draw.Src)
png.Encode(part, im)
mw.Close()
req := httptest.NewRequest("POST", "/api/"+action, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if tok == "admin" {
for _, cookie := range adminCookies {
req.AddCookie(cookie)
}
} else {
req.Header.Set("Authorization", "Bearer "+tok)
}
out := httptest.NewRecorder()
engine.ServeHTTP(out, req)
covered[action]++
if out.Code != 200 {
t.Fatalf("upload %s: %d %s", action, out.Code, out.Body.String())
}
happy[action] = true
var result M
if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return normalizeAPIMap(result)
}
get := func(a, tok string, b M) M { return request(a, "GET", tok, b, 200) }
post := func(a, tok string, b M) M { return request(a, "POST", tok, b, 200) }
id := func(r M, key string) string {
t.Helper()
value := store.ToStr(r[key])
if !store.ValidUUID(value) {
t.Fatalf("%s is not UUID: %v", key, r)
}
return value
}
a := post("register", "", M{"phone": "13800000001", "password": "testpass1", "nickname": "Alpha", "gender": "male"})
aID, aToken := id(a, "user_id"), store.ToStr(a["token"])
b := post("register", "", M{"phone": "13800000002", "password": "testpass2", "nickname": "Beta", "gender": "female"})
bID, bToken := id(b, "user_id"), store.ToStr(b["token"])
outsider := post("register", "", M{"phone": "13800000003", "password": "testpass3", "nickname": "Other"})
otherToken := store.ToStr(outsider["token"])
post("login", "", M{"phone": "13800000001", "username": "13800000001", "password": "testpass1"})
get("check_auth", aToken, nil)
get("my_info", aToken, nil)
personalUpload := uploadFile("upload", aToken, "file", nil)
personalImage := id(personalUpload, "file_id")
// Preflight uses the real current allowance and accepts already-owned duplicates.
var quotaFile model.File
if err := db.ORM.First(&quotaFile, "id=?", personalImage).Error; err != nil {
t.Fatal(err)
}
request("upload_preflight", "POST", "", M{"size": 100, "md5": strings.Repeat("a", 32)}, 401)
post("upload_preflight", aToken, M{"size": quotaFile.Size, "md5": quotaFile.Md5})
request("upload_preflight", "POST", aToken, M{"size": -1, "md5": "bad"}, 400)
if err := db.ORM.Model(&model.User{}).Where("id=?", aID).Update("storage_quota_mb", 1).Error; err != nil {
t.Fatal(err)
}
post("upload_preflight", aToken, M{"size": 1024*1024 - quotaFile.Size, "md5": strings.Repeat("a", 32)})
blocked := request("upload_preflight", "POST", aToken, M{"size": 1024 * 1024, "md5": strings.Repeat("a", 32)}, 403)
if store.ToInt(blocked["code"]) != 10001 {
t.Fatalf("quota error code missing: %v", blocked)
}
// A dishonest client can pass advisory metadata, but actual upload must reject
// its different body without creating a file or granting ownership.
post("upload_preflight", aToken, M{"size": quotaFile.Size, "md5": quotaFile.Md5})
var quotaPNG bytes.Buffer
png.Encode(&quotaPNG, image.NewRGBA(image.Rect(0, 0, 3, 3)))
oversizedBody := append(quotaPNG.Bytes(), make([]byte, 2*1024*1024)...)
if result := uploader.SaveRawFor(oversizedBody, "png", aID); result.Code != 10001 {
t.Fatalf("forged preflight bypassed actual quota: %+v", result)
}
if err := db.ORM.Model(&model.User{}).Where("id=?", aID).Update("storage_quota_mb", 0).Error; err != nil {
t.Fatal(err)
}
// Every record endpoint rejects an authenticated but unbound account.
for _, action := range []string{"diary_list", "diary_add", "timeline_add", "album_add", "photos", "photo_add", "photo_upload", "set_album_cover", "songs_add", "checkin_add", "smoking_card_visible", "period_daily_save", "pet_adopt"} {
denied := request(action, "POST", aToken, M{}, 403)
if store.ToInt(denied["code"]) != 10002 {
t.Fatalf("missing binding code for %s: %v", action, denied)
}
}
link := post("generate_invite_link", aToken, nil)
_ = link
post("update_profile", aToken, M{"nickname": "Alpha updated", "gender": "male"})
inv := post("invite_partner", aToken, M{"target_user_id": b["display_id"]})
couple := post("respond_invitation", bToken, M{"invitation_id": id(inv, "invitation_id"), "accept": true})
cid := id(couple, "couple_id")
for _, uid := range []string{aID, bID} {
var user model.User
if err := db.ORM.First(&user, "id=?", uid).Error; err != nil || user.CoupleId == nil || *user.CoupleId != cid {
t.Fatalf("binding mismatch: %+v %v", user, err)
}
}
personalDiary := id(post("diary_add", aToken, M{"content": "Personal diary", "image_file_ids": []string{personalImage}}), "id")
get("diary_detail", aToken, M{"id": personalDiary})
request("diary_detail", "GET", otherToken, M{"id": personalDiary}, 403)
personalMemory := id(post("timeline_add", aToken, M{"title": "Personal memory", "event_date": today(), "bg_image_file_id": personalImage}), "id")
get("timeline_detail", aToken, M{"id": personalMemory})
request("timeline_detail", "GET", otherToken, M{"id": personalMemory}, 403)
// Bound users can use albums and all associated file operations.
personalAlbum := id(post("album_add", aToken, M{"title": "Personal album"}), "id")
personalFile := id(uploadFile("upload", aToken, "file", nil), "file_id")
personalPhoto := id(post("photo_add", aToken, M{"album_id": personalAlbum, "file_id": personalFile}), "id")
uploadFile("set_album_cover", aToken, "cover", M{"album_id": personalAlbum})
uploadFile("photo_upload", aToken, "photos", M{"album_id": personalAlbum})
post("photo_update", aToken, M{"id": personalPhoto, "taken_at": today(), "location": "Personal place", "activity": "Personal photo"})
post("album_update", aToken, M{"id": personalAlbum, "title": "Personal edited"})
if got := get("photos", aToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
t.Fatalf("personal photos missing: %v", got)
}
request("photos", "GET", otherToken, M{"album_id": personalAlbum}, 403)
request("album_list", "GET", otherToken, nil, 403)
request("photo_add", "POST", otherToken, M{"album_id": personalAlbum, "file_id": personalFile}, 403)
request("photo_update", "POST", otherToken, M{"id": personalPhoto}, 403)
request("album_delete", "POST", otherToken, M{"id": personalAlbum}, 403)
var personal model.Album
if err := db.ORM.First(&personal, "id=?", personalAlbum).Error; err != nil || personal.CoupleId == nil || *personal.CoupleId != cid {
t.Fatalf("personal album relation: %+v %v", personal, err)
}
if got := get("photos", bToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
t.Fatalf("partner cannot view shared photos: %v", got)
}
post("photo_update", bToken, M{"id": personalPhoto, "taken_at": today(), "location": "Shared now", "activity": "Partner edit"})
request("account_deletion_preview", "POST", aToken, nil, 409)
post("update_couple_info", aToken, M{"love_start_date": "2026-01-01", "love_story": "story"})
diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}})
diaryID := id(diary, "id")
post("diary_update", aToken, M{"id": diaryID, "title": "Diary edited", "content": "new body"})
comment := post("diary_comment_add", bToken, M{"diary_id": diaryID, "content": "Comment B"})
_ = comment
timeline := post("timeline_add", aToken, M{"title": "Memory", "content": "memory body", "event_date": today()})
timelineID := id(timeline, "id")
post("timeline_update", aToken, M{"id": timelineID, "title": "Memory edited", "content": "changed", "event_date": today()})
song := post("songs_add", aToken, M{"song_name": "Song", "artist": "Artist"})
songID := id(song, "id")
post("songs_update", aToken, M{"id": songID, "song_name": "Song edited", "artist": "Artist"})
album := post("album_add", aToken, M{"title": "Album", "description": "description"})
albumID := id(album, "id")
post("album_update", aToken, M{"id": albumID, "title": "Album edited", "description": "changed"})
uploadMeta := uploadFile("upload", aToken, "file", nil)
fileId := id(uploadMeta, "file_id")
photo := post("photo_add", aToken, M{"album_id": albumID, "file_id": fileId, "description": "photo"})
photoID := id(photo, "id")
post("photo_update", aToken, M{"id": photoID, "taken_at": today(), "location": "location", "activity": "activity"})
uploadFile("set_album_cover", aToken, "cover", M{"album_id": albumID})
check := post("checkin_add", aToken, M{"title": "Walk", "type": "dual", "frequency": "daily"})
checkID := id(check, "id")
post("checkin_do", aToken, M{"id": checkID})
post("checkin_do", bToken, M{"id": checkID})
post("checkin_uncheck", bToken, M{"id": checkID})
post("checkin_toggle", aToken, M{"id": checkID})
post("smoking_action", aToken, M{"kind": "purchase", "quantity": 20, "amount_cents": 1000, "request_key": "purchase-1"})
post("smoking_action", aToken, M{"kind": "purchase", "quantity": 20, "amount_cents": 1000, "request_key": "purchase-1"})
post("smoking_action", aToken, M{"kind": "consume", "quantity": 1, "request_key": "consume-1"})
wallet, _ := db.QueryOne("SELECT stock FROM smoking_wallets WHERE user_id=?", aID)
if wallet.Int("stock") != 19 {
t.Fatalf("idempotency/inventory mismatch: %v", wallet)
}
post("smoking_card_visible", aToken, M{"visible": 0})
period := post("period_add", bToken, M{"start_date": today(), "end_date": today(), "cycle_length": 28, "period_length": 5})
periodID := id(period, "id")
post("period_update", bToken, M{"id": periodID, "start_date": today(), "end_date": today(), "cycle_length": 29, "period_length": 5})
post("period_daily_save", bToken, M{"record_date": today(), "period": "2", "sex": "0", "note": "note"})
sex := post("period_sex_add", bToken, M{"record_date": today(), "record_time": "12:30", "measure": "安全套"})
_ = sex
post("period_visible_set", bToken, M{"period_visible": 1})
pet := post("pet_adopt", aToken, M{"name": "Test pet", "breed": "golden", "gender": 1})
_ = pet
post("pet_rename", aToken, M{"name": "Pet renamed"})
post("pet_daily_action", aToken, M{"action": "feed"})
post("pet_equip", aToken, M{"item_key": "collar_basic", "equip": 1})
post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "notify"})
post("unsubscribe_template", aToken, M{"template_type": "notify"})
post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "checkin"})
sharedNotify := get("my_subscribe_templates", aToken, nil)
if sharedNotify["templates"].(M)["notify"] != "test-template" {
t.Fatalf("subscription not shared: %v", sharedNotify)
}
post("unsubscribe_template", aToken, M{"template_type": "checkin"})
if _, err := db.Exec("INSERT INTO subscribe_templates (user_id,template_type,template_id) VALUES (?,?,?)", aID, "checkin", "test-template"); err != nil {
t.Fatal(err)
}
legacy := get("my_subscribe_templates", aToken, nil)
if legacy["templates"].(M)["notify"] != "test-template" {
t.Fatalf("existing checkin authorization lost: %v", legacy)
}
post("unsubscribe_template", aToken, M{"template_type": "notify"})
if len(get("my_subscribe_templates", aToken, nil)["templates"].([]any)) != 0 {
t.Fatal("shared authorization not removed")
}
post("admin_login", "", M{"username": "past", "password": "past"})
uploadFile("upload", aToken, "file", M{"type": "diary"})
uploadFile("upload_avatar", aToken, "avatar", nil)
uploadFile("upload_partner_avatar", aToken, "avatar", M{"partner_id": bID})
uploadFile("set_bg_image", aToken, "bg", nil)
repeatedBackground := uploadFile("set_bg_image", aToken, "bg", nil)
if repeatedBackground["duplicate"] != true {
t.Fatal("identical background uploaded more than once")
}
var storedBackground model.Setting
if err := db.ORM.First(&storedBackground, "key=?", "bg_"+cid).Error; err != nil || storedBackground.FileId == nil || storedBackground.Value != *storedBackground.FileId {
t.Fatalf("background association did not store a UUID: %+v %v", storedBackground, err)
}
globalUpload := uploadFile("admin_set_global_bg", "admin", "bg", nil)
globalFileID := id(globalUpload, "file_id")
if globalFileID == "" || globalUpload["duplicate"] != true || globalUpload["path"] != "/api/files/"+globalFileID {
t.Fatalf("admin upload missing stable file metadata: %+v", globalUpload)
}
var globalSetting model.Setting
if err := db.ORM.First(&globalSetting, "key=?", "bg_global").Error; err != nil || globalSetting.FileId == nil || *globalSetting.FileId != globalFileID || globalSetting.Value != globalFileID {
t.Fatalf("global background association did not store upload ID: %+v %v", globalSetting, err)
}
globalBg := get("admin_get_global_bg", "admin", nil)
if globalBg["bg_image_file_id"] != globalFileID {
t.Fatalf("admin background query lost file ID: %+v", globalBg)
}
shared := M{"id": diaryID, "diary_id": diaryID, "album_id": albumID, "couple_id": cid, "user_id": aID, "record_date": today(), "date": today(), "type": "diary"}
for _, action := range []string{"site_info", "site_stats", "my_info", "my_invitations", "couple_info", "check_invite_data_status", "diary_list", "diary_detail", "diary_comment_list", "timeline_list", "timeline_detail", "songs_list", "album_list", "photos", "checkin_list", "checkin_detail", "checkin_stats", "smoking_info", "period_list", "period_visible_get", "period_daily_get", "period_sex_list", "pet_info", "pet_daily_status", "pet_achievements", "pet_dynamics", "activity_log_list", "my_subscribe_templates", "storage_info", "get_bg_config", "unbind_status"} {
args := M{}
for k, v := range shared {
args[k] = v
}
if strings.HasPrefix(action, "timeline") {
args["id"] = timelineID
}
if strings.HasPrefix(action, "checkin") {
args["id"] = checkID
}
get(action, aToken, args)
}
for _, action := range []string{"admin_check_auth", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_pets", "admin_pet_detail", "admin_contents", "admin_activity_log", "admin_get_global_bg", "admin_couple_bg_list", "admin_unbind_requests", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_orders"} {
args := M{}
for k, v := range shared {
args[k] = v
}
args["id"] = cid
if action == "admin_user_detail" {
args["id"] = aID
}
get(action, "admin", args)
}
// Cross-account access and mutations must not expose or overwrite another couple's data.
for _, action := range []string{"diary_detail", "timeline_detail", "checkin_detail"} {
args := M{"id": diaryID}
if action == "timeline_detail" {
args["id"] = timelineID
}
if action == "checkin_detail" {
args["id"] = checkID
}
request(action, "GET", otherToken, args, 0)
}
request("diary_update", "POST", otherToken, M{"id": diaryID, "title": "intrusion", "content": "bad"}, 403)
detail := get("diary_detail", aToken, M{"id": diaryID})
if strings.Contains(fmt.Sprint(detail), "intrusion") {
t.Fatal("foreign account overwrote diary")
}
post("photo_delete", aToken, M{"id": photoID})
post("album_delete", aToken, M{"id": albumID})
post("songs_delete", aToken, M{"id": songID})
post("timeline_delete", aToken, M{"id": timelineID})
post("checkin_delete", aToken, M{"id": checkID})
post("diary_delete", aToken, M{"id": diaryID})
var deleted model.Diary
if err := db.ORM.Unscoped().First(&deleted, "id=?", diaryID).Error; err != nil || !deleted.DeletedAt.Valid || deleted.Title != "Diary edited" {
t.Fatalf("deleted diary/data mismatch %+v %v", deleted, err)
}
// Remaining mutation routes use valid fixtures, with explicit persistence checks.
post("update_location", aToken, M{"lat": 31.23, "lng": 121.47, "location": "Shanghai"})
post("update_ip_location", aToken, nil)
post("update_partner_profile", aToken, M{"partner_id": bID, "nickname": "Beta updated", "age": 25})
post("period_source_select", bToken, M{"user_id": bID})
post("period_sex_delete", bToken, M{"id": id(sex, "id")})
post("period_delete", bToken, M{"id": periodID, "start_date": today(), "end_date": today()})
commentRow := comment["comment"].(M)
post("diary_comment_delete", bToken, M{"id": id(commentRow, "id")})
post("delete_bg_image", aToken, nil)
post("admin_clear_couple_bg", "admin", M{"couple_id": cid})
post("admin_update_user", "admin", M{"user_id": aID, "nickname": "Admin edited A", "age": 27})
post("admin_storage_user_update", "admin", M{"user_id": aID, "quota_mb": 500, "plan_key": "month"})
post("admin_reset_user_password", "admin", M{"user_id": outsider["user_id"], "new_password": "resetpass"})
post("login", "", M{"phone": "13800000003", "password": "resetpass"})
post("admin_change_password", "admin", M{"old_password": "past", "new_password": "admin-test-pass"})
post("admin_change_username", "admin", M{"new_username": "integration-admin"})
for _, term := range []string{store.ToStr(a["display_id"]), aID, "Alpha"} {
get("admin_users", "admin", M{"search": term})
get("admin_storage_users", "admin", M{"search": term})
}
// Numeric public IDs and UUID primary keys are separate and both searchable.
var persisted model.User
if err := db.ORM.First(&persisted, "id=?", aID).Error; err != nil || persisted.Nickname != "Admin edited A" || persisted.StorageQuotaMb != 500 {
t.Fatalf("profile/storage write mismatch: %+v %v", persisted, err)
}
post("admin_storage_settings", "admin", M{"free_mb": 200, "month_price_cents": 500, "year_price_cents": 2990})
exerciseWechatPayment(t, db, app, engine, request, covered, happy, aID, aToken, otherToken)
exerciseStorageTiers(t, db, app, request, aID, aToken, bToken)
post("wechat_login", "", M{"code": "mock-wechat-code"})
// Three separate couples verify all binding entry points without reusing owners.
d := post("register", "", M{"phone": "13800000004", "password": "testpass4", "nickname": "D"})
dToken := store.ToStr(d["token"])
post("invite_by_phone", otherToken, M{"phone": "13800000004"})
post("accept_share_invite", dToken, M{"inviter_id": outsider["display_id"]})
foreignDiary := post("diary_add", aToken, M{"title": "Private A", "content": "private", "diary_date": today()})
foreignID := id(foreignDiary, "id")
request("diary_detail", "GET", otherToken, M{"id": foreignID}, 404)
request("diary_update", "POST", otherToken, M{"id": foreignID, "title": "intrusion", "content": "intrusion"}, 404)
post("diary_delete", otherToken, M{"id": foreignID})
var protected model.Diary
if err := db.ORM.First(&protected, "id=?", foreignID).Error; err != nil || protected.Title != "Private A" || (protected.CreatedBy == nil || *protected.CreatedBy != aID) || (protected.CoupleId == nil || *protected.CoupleId != cid) {
t.Fatalf("cross-couple write/delete corrupted data: %+v %v", protected, err)
}
e := post("register", "", M{"phone": "13800000005", "password": "testpass5", "nickname": "E"})
eToken := store.ToStr(e["token"])
eLink := post("generate_invite_link", eToken, nil)
get("check_invite_code", "", M{"code": eLink["code"]})
f := post("register_by_invite", "", M{"phone": "13800000006", "password": "testpass6", "nickname": "F", "invite_code": eLink["code"]})
_ = f
// Multipart photo upload stores the correct album UUID and files remain after logical deletion.
defaultAlbum, _ := db.QueryOne("SELECT id FROM albums WHERE couple_id=? ORDER BY created_at LIMIT 1", cid)
uploadFile("photo_upload", aToken, "photos", M{"album_id": defaultAlbum.Str("id")})
uploaded, _ := db.QueryOne("SELECT id,path,created_by FROM photos WHERE album_id=? ORDER BY created_at DESC LIMIT 1", defaultAlbum.Str("id"))
if uploaded == nil || uploaded.Str("created_by") != aID {
t.Fatalf("uploaded photo ownership mismatch: %v", uploaded)
}
post("admin_delete_content", "admin", M{"type": "photo", "id": uploaded.Str("id"), "couple_id": cid})
var retainedFile model.File
if err := db.ORM.First(&retainedFile, "id=?", store.FileIdentifier(uploaded.Str("path"))).Error; err != nil {
t.Fatal(err)
}
fileRequest := httptest.NewRequest("GET", store.FileAccessPath(retainedFile.Id), nil)
fileResponse := httptest.NewRecorder()
engine.ServeHTTP(fileResponse, fileRequest)
if fileResponse.Code != 200 || fileResponse.Body.Len() == 0 || fileResponse.Header().Get("Cache-Control") != "private, max-age=3600" {
t.Fatalf("file cache response: %d %v", fileResponse.Code, fileResponse.Header())
}
cachedReq := httptest.NewRequest("GET", store.FileAccessPath(retainedFile.Id), nil)
cachedReq.Header.Set("If-None-Match", fileResponse.Header().Get("ETag"))
cached := httptest.NewRecorder()
engine.ServeHTTP(cached, cachedReq)
if cached.Code != 304 || cached.Body.Len() != 0 {
t.Fatalf("conditional image request: %d", cached.Code)
}
// Cold-period cancellation must retain the active relationship and inventory.
post("unbind_request", aToken, nil)
post("unbind_respond", bToken, M{"agree": true})
post("unbind_cancel", aToken, nil)
post("unbind_request", aToken, nil)
post("unbind_respond", bToken, M{"agree": true})
if _, err := db.Exec("UPDATE unbind_requests SET cooling_until=NOW() - INTERVAL '1 second' WHERE couple_id=? AND status='cooling'", cid); err != nil {
t.Fatal(err)
}
get("unbind_status", aToken, nil)
post("unbind_couple", aToken, nil)
post("admin_force_unbind", "admin", M{"couple_id": cid})
post("admin_delete_couple_data", "admin", M{"couple_id": cid})
request("admin_restore_couple", "POST", "admin", M{"couple_id": cid}, 400)
for _, uid := range []string{aID, bID} {
var user model.User
db.ORM.First(&user, "id=?", uid)
if user.CoupleId != nil {
t.Fatal("unbinding left user attached to old couple")
}
}
wallet, _ = db.QueryOne("SELECT stock FROM smoking_wallets WHERE user_id=?", aID)
if wallet.Int("stock") != 19 {
t.Fatal("unbinding changed personal inventory")
}
// File management must return deduplicated metadata and every uploader, with admin-only access.
request("admin_files", "GET", aToken, nil, 401)
managed := get("admin_files", "admin", M{"page": 1, "page_size": 1, "suffix": "png", "keyword": "integration"})
fileItems := managed["items"].([]any)
if len(fileItems) != 1 || store.ToInt(managed["total"]) != 1 {
t.Fatalf("file management pagination/filter mismatch: %v", managed)
}
managedFile := fileItems[0].(M)
if managedFile["id"] != fileId || managedFile["content_type"] != "image/png" || store.ToInt(managedFile["size"]) <= 0 || len(managedFile["uploaders"].([]any)) != 2 {
t.Fatalf("file metadata/uploaders missing: %v", managedFile)
}
if store.ToInt(get("admin_files", "admin", M{"user_id": bID})["total"]) != 1 {
t.Fatal("file owner filtering failed")
}
if store.ToInt(get("admin_files", "admin", M{"keyword": "no-such-file"})["total"]) != 0 {
t.Fatal("file search returned unrelated rows")
}
// Admin deletion of another couple cannot change a third couple's references.
post("admin_delete_user", "admin", M{"user_id": d["user_id"]})
var survivor model.User
if err := db.ORM.First(&survivor, "id=?", e["user_id"]).Error; err != nil || survivor.CoupleId == nil {
t.Fatalf("deletion affected unrelated couple: %+v %v", survivor, err)
}
// Announcement administration is protected and disabled text is not exposed.
request("admin_announcement", "POST", aToken, M{"enabled": true, "title": "test", "content": "notice"}, 401)
post("admin_announcement", "admin", M{"enabled": true, "title": "公告", "content": "Test announcement"})
get("admin_announcement", "admin", nil)
notice := get("announcement", aToken, nil)
if notice["enabled"] != true || notice["content"] != "Test announcement" {
t.Fatalf("announcement missing: %v", notice)
}
post("admin_announcement", "admin", M{"enabled": false, "title": "公告", "content": "Retained draft"})
if n := get("announcement", aToken, nil); n["enabled"] != false || n["content"] != "" || n["revision"] == notice["revision"] {
t.Fatalf("announcement disable failed: %v", n)
}
request("smoking_card_visible", "POST", aToken, M{"visible": 0}, 403)
post("admin_announcement", "admin", M{"mode": "always", "title": "Always", "content": "Every visit"})
if n := get("announcement", aToken, nil); n["mode"] != "always" || n["enabled"] != true {
t.Fatalf("always mode failed: %v", n)
}
post("admin_announcement", "admin", M{"mode": "login_once", "title": "Once", "content": "Each login"})
if n := get("announcement", aToken, nil); n["mode"] != "login_once" {
t.Fatalf("login mode failed: %v", n)
}
post("admin_announcement", "admin", M{"mode": "always", "title": "Blank", "content": " "})
if n := get("announcement", aToken, nil); n["enabled"] != false {
t.Fatalf("blank announcement enabled: %v", n)
}
post("admin_announcement", "admin", M{"mode": "off", "title": "Draft", "content": "Hidden draft"})
if n := get("admin_announcement", "admin", nil); n["content"] != "Hidden draft" {
t.Fatal("disabled draft was lost")
}
request("admin_announcement", "POST", "admin", M{"mode": "invalid", "content": "bad"}, 400)
// Account deletion includes unsaved uploads and previously soft-deleted content.
deleteUser := post("register", "", M{"phone": "13800000007", "password": "deletepass6", "nickname": "Disposable"})
deleteToken := store.ToStr(deleteUser["token"])
otherID := store.ToStr(deleteUser["user_id"])
ownFile := id(uploadFile("upload", deleteToken, "file", nil), "file_id")
var uniquePNG bytes.Buffer
uniqueImage := image.NewRGBA(image.Rect(0, 0, 16, 16))
draw.Draw(uniqueImage, uniqueImage.Bounds(), image.NewUniform(color.RGBA{R: 201, G: 92, B: 111, A: 255}), image.Point{}, draw.Src)
png.Encode(&uniquePNG, uniqueImage)
uniqueUpload := uploader.SaveRawFor(uniquePNG.Bytes(), "png", otherID)
uniqueURL, _ := uploader.PresignedURL(context.Background(), uniqueUpload.FileId)
if uniqueUpload.Error != "" {
t.Fatal(uniqueUpload.Error)
}
// Seed historical personal content directly: new unbound writes are forbidden,
// but account deletion must still remove records created under the old policy.
legacyAlbum := model.Album{Title: "Delete personal", CreatedBy: &otherID}
if err := db.ORM.Create(&legacyAlbum).Error; err != nil {
t.Fatal(err)
}
for _, fid := range []string{ownFile, uniqueUpload.FileId} {
photo := model.Photo{AlbumId: legacyAlbum.Id, FileId: &fid, Path: "/api/files/" + fid, CreatedBy: &otherID}
if err := db.ORM.Create(&photo).Error; err != nil {
t.Fatal(err)
}
}
legacyDiary := model.Diary{Title: "Legacy diary", Content: "Delete this diary", DiaryDate: today(), CreatedBy: &otherID}
if err := db.ORM.Create(&legacyDiary).Error; err != nil {
t.Fatal(err)
}
deletedDiary := legacyDiary.Id
if err := db.ORM.Delete(&legacyDiary).Error; err != nil {
t.Fatal(err)
}
request("account_delete", "POST", deleteToken, M{}, 400)
preview := post("account_deletion_preview", deleteToken, nil)
addedLegacy := model.Album{Title: "Added after preview", CreatedBy: &otherID}
if err := db.ORM.Create(&addedLegacy).Error; err != nil {
t.Fatal(err)
}
confirm := M{"confirmed": true, "confirmation_text": "注销并删除全部数据", "confirmation_token": preview["confirmation_token"]}
request("account_delete", "POST", deleteToken, confirm, 409)
preview = post("account_deletion_preview", deleteToken, nil)
confirm["confirmation_token"] = preview["confirmation_token"]
post("account_delete", deleteToken, confirm)
request("album_list", "GET", deleteToken, nil, 401)
for _, target := range []any{&model.User{}, &model.UserToken{}, &model.Album{}, &model.Photo{}, &model.Diary{}, &model.FileUpload{}, &model.RecordFile{}} {
var n int64
q := db.ORM.Unscoped().Model(target)
switch target.(type) {
case *model.User:
q = q.Where("id=?", otherID)
case *model.Album, *model.Photo, *model.Diary:
q = q.Where("created_by=?", otherID)
case *model.RecordFile:
q = q.Where("owner_id=?", deletedDiary)
default:
q = q.Where("user_id=?", otherID)
}
if err := q.Count(&n).Error; err != nil || n != 0 {
t.Fatalf("account data left behind %T: %d %v", target, n, err)
}
}
// Failure to reach object storage must leave a durable deletion job for retry.
var pending int64
if err := db.ORM.Model(&model.FileDeletion{}).Where("file_id=?", uniqueUpload.FileId).Count(&pending).Error; err != nil || pending != 1 {
t.Fatalf("missing cleanup job: %d %v", pending, err)
}
if err := uploader.CleanupDeletedFiles(); err != nil {
t.Fatal(err)
}
var gone int64
db.ORM.Unscoped().Model(&model.File{}).Where("id=?", uniqueUpload.FileId).Count(&gone)
if gone != 0 {
t.Fatal("exclusive file metadata remains")
}
if response, err := uploader.HTTPClient.Get(uniqueURL); err != nil {
t.Fatal(err)
} else {
response.Body.Close()
if response.StatusCode == 200 {
t.Fatal("exclusive object remains in MinIO")
}
}
if err := db.ORM.First(&model.File{}, "id=?", ownFile).Error; err != nil {
t.Fatalf("deduplicated file of another user deleted: %v", err)
}
// Every registered action must at least exercise its real authentication gate.
actions := router.Actions()
sort.Strings(actions)
for _, action := range actions {
if covered[action] == 0 {
token := aToken
if strings.HasPrefix(action, "admin_") {
token = "admin"
}
request(action, "GET", token, shared, 0)
}
}
// Every private route must reject anonymous access before touching user data.
public := map[string]bool{"register": true, "login": true, "wechat_login": true, "site_info": true, "site_stats": true, "admin_login": true, "check_invite_code": true, "register_by_invite": true, "check_auth": true, "wechat_pay_notify": true}
for _, action := range router.Actions() {
if !public[action] {
request(action, "GET", "", nil, 401)
}
}
var remaining []string
for _, action := range actions {
if !happy[action] {
remaining = append(remaining, action)
}
}
if len(covered) != len(actions) || len(remaining) != 1 || remaining[0] != "admin_restore_couple" {
t.Fatalf("incomplete API coverage: %v", remaining)
}
t.Logf("registered=%d reached=%d successful=%d intentional_business_rejection=%v", len(actions), len(covered), len(happy), remaining)
}
func normalizeAPIMap(m M) M {
out := M{}
re := regexp.MustCompile(`[A-Z]`)
for key, value := range m {
key = re.ReplaceAllStringFunc(key, func(c string) string { return "_" + strings.ToLower(c) })
switch v := value.(type) {
case map[string]any:
value = normalizeAPIMap(M(v))
case []any:
for i, item := range v {
if child, ok := item.(map[string]any); ok {
v[i] = normalizeAPIMap(M(child))
}
}
value = v
}
out[key] = value
}
return out
}
+30 -31
View File
@@ -6,6 +6,7 @@ import (
"past/internal/httpapi" "past/internal/httpapi"
"past/internal/store" "past/internal/store"
"past/internal/wechat"
) )
func registerCheckin(r *httpapi.Router) { func registerCheckin(r *httpapi.Router) {
@@ -66,8 +67,8 @@ func periodDone(freq string, last string, has bool, today, monday, month string)
} }
// decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段 // decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段
func decorateCheckin(item store.Row, uid int64, withAvatar bool) { func decorateCheckin(item store.Row, uid string, withAvatar bool) {
isCreator := uid == item.Int("created_by") isCreator := uid == item.Str("created_by")
freq := store.ToStr(item["frequency"]) freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") { if item.IsNull("frequency") {
freq = "daily" freq = "daily"
@@ -155,7 +156,7 @@ func decorateCheckin(item store.Row, uid int64, withAvatar bool) {
} }
func checkinList(c *httpapi.Ctx) error { func checkinList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
if c.QHas("page") || c.QHas("page_size") { if c.QHas("page") || c.QHas("page_size") {
return demoListResponse(c, "checkin", M{"week_both_count": 0, "checkin_log_total": 0}) return demoListResponse(c, "checkin", M{"week_both_count": 0, "checkin_log_total": 0})
@@ -173,7 +174,7 @@ func checkinList(c *httpapi.Ctx) error {
} }
total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = checkins.id)", ids[0], ids[1]) total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = checkins.id)", ids[0], ids[1])
var logTotal int64 var logTotal int64
if cid := c.CoupleID(); cid != 0 { if cid := c.CoupleID(); cid != "" {
logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid) logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid)
} }
if c.QHas("page") || c.QHas("page_size") { if c.QHas("page") || c.QHas("page_size") {
@@ -212,8 +213,8 @@ func checkinAdd(c *httpapi.Ctx) error {
} }
// loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404) // loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404)
func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) { func loadCheckinItem(c *httpapi.Ctx, id string) (store.Row, error) {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
var item store.Row var item store.Row
if len(ids) == 2 { if len(ids) == 2 {
var err error var err error
@@ -236,15 +237,15 @@ func checkinDo(c *httpapi.Ctx) error {
return err return err
} }
cid, uid := c.CoupleID(), c.UID() cid, uid := c.CoupleID(), c.UID()
id := c.InInt("id") id := c.InTrim("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少打卡ID") return c.Err(400, "缺少打卡ID")
} }
item, err := loadCheckinItem(c, id) item, err := loadCheckinItem(c, id)
if err != nil { if err != nil {
return err return err
} }
isCreator := uid == item.Int("created_by") isCreator := uid == item.Str("created_by")
td, nowS := today(), now() td, nowS := today(), now()
freq := store.ToStr(item["frequency"]) freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") { if item.IsNull("frequency") {
@@ -300,15 +301,10 @@ func checkinDo(c *httpapi.Ctx) error {
c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id) c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id)
} }
title := item.Str("title") title := item.Str("title")
actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "") actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", store.ToStr(note))
c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note) c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", wechat.CheckinMessage(title, store.ToStr(note), nowS), "pages/punch/punch", actID, uid)
"thing1": "打卡「" + mbTruncate(title, 20) + "」",
"thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak),
"date3": td,
"thing4": "点击查看详情",
}, "pages/punch/punch", actID, uid)
} }
return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ}) return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ})
} }
@@ -318,8 +314,8 @@ func checkinUncheck(c *httpapi.Ctx) error {
return err return err
} }
uid := c.UID() uid := c.UID()
id := c.InInt("id") id := c.InTrim("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少打卡ID") return c.Err(400, "缺少打卡ID")
} }
item, err := loadCheckinItem(c, id) item, err := loadCheckinItem(c, id)
@@ -327,7 +323,7 @@ func checkinUncheck(c *httpapi.Ctx) error {
return err return err
} }
sql := "UPDATE checkins SET completed_by_me = 0, my_checkin_time = NULL, my_streak = 0, my_photo = '', my_note = '', is_completed = 0 WHERE id = ?" sql := "UPDATE checkins SET completed_by_me = 0, my_checkin_time = NULL, my_streak = 0, my_photo = '', my_note = '', is_completed = 0 WHERE id = ?"
if uid != item.Int("created_by") { if uid != item.Str("created_by") {
sql = "UPDATE checkins SET completed_by_partner = 0, partner_checkin_time = NULL, partner_streak = 0, partner_photo = '', partner_note = '', is_completed = 0 WHERE id = ?" sql = "UPDATE checkins SET completed_by_partner = 0, partner_checkin_time = NULL, partner_streak = 0, partner_photo = '', partner_note = '', is_completed = 0 WHERE id = ?"
} }
if _, err := c.DB.Exec(sql, id); err != nil { if _, err := c.DB.Exec(sql, id); err != nil {
@@ -337,12 +333,12 @@ func checkinUncheck(c *httpapi.Ctx) error {
} }
func checkinDetail(c *httpapi.Ctx) error { func checkinDetail(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
return c.OK([]any{}) return c.OK([]any{})
} }
id := c.QInt("id") id := c.Q("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少打卡ID") return c.Err(400, "缺少打卡ID")
} }
item, err := c.DB.QueryOne("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.id = ? AND c.created_by IN (?, ?)", id, ids[0], ids[1]) item, err := c.DB.QueryOne("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.id = ? AND c.created_by IN (?, ?)", id, ids[0], ids[1])
@@ -372,7 +368,7 @@ func checkinDetail(c *httpapi.Ctx) error {
var total, myT, myW, myM, myY, pT, pW, pM, pY int64 var total, myT, myW, myM, myY, pT, pW, pM, pY int64
for _, row := range stats { for _, row := range stats {
total += row.Int("total") total += row.Int("total")
if row.Int("user_id") == uid { if row.Str("user_id") == uid {
myT, myW, myM, myY = row.Int("today_count"), row.Int("week_count"), row.Int("month_count"), row.Int("year_count") myT, myW, myM, myY = row.Int("today_count"), row.Int("week_count"), row.Int("month_count"), row.Int("year_count")
} else { } else {
pT += row.Int("today_count") pT += row.Int("today_count")
@@ -389,21 +385,24 @@ func checkinDetail(c *httpapi.Ctx) error {
} }
func checkinStats(c *httpapi.Ctx) error { func checkinStats(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
ids = []int64{c.UID(), 0} ids = []string{c.UID(), "00000000-0000-0000-0000-000000000000"}
} }
td, ws := today(), weekMonday() td, ws := today(), weekMonday()
uid, partner := c.UID(), partnerUserId(c.DB, c.UID()) uid, partner := c.UID(), partnerUserId(c.DB, c.UID())
smokeCount := func(owner int64, since string) int64 { smokeCount := func(owner string, since string) int64 {
if owner == "" {
return 0
}
return countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= ?", owner, since) return countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= ?", owner, since)
} }
return c.OK(M{ return c.OK(M{
"week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(uid, ws), "week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(uid, ws),
"week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(partner, ws), "week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(partner, ws),
"week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws), "week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws),
"today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(uid, td), "today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND DATE(my_checkin_time) = ?", ids[0], ids[1], td) + smokeCount(uid, td),
"today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(partner, td), "today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND DATE(partner_checkin_time) = ?", ids[0], ids[1], td) + smokeCount(partner, td),
}) })
} }
@@ -416,7 +415,7 @@ func checkinToggle(c *httpapi.Ctx) error {
return err return err
} }
id := c.In("id") id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil { if err := guardSmokingCheckin(c, c.InTrim("id")); err != nil {
return err return err
} }
cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid) cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid)
@@ -452,7 +451,7 @@ func checkinDelete(c *httpapi.Ctx) error {
return err return err
} }
id := c.In("id") id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil { if err := guardSmokingCheckin(c, c.InTrim("id")); err != nil {
return err return err
} }
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id) c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
+41 -52
View File
@@ -6,8 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand" "math/rand"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
"unicode/utf16" "unicode/utf16"
@@ -26,6 +24,7 @@ var up *upload.Uploader
// Register 注册全部 action // Register 注册全部 action
func Register(r *httpapi.Router, uploader *upload.Uploader) { func Register(r *httpapi.Router, uploader *upload.Uploader) {
up = uploader up = uploader
r.SetFileStorage(uploader)
registerUser(r) registerUser(r)
registerCouple(r) registerCouple(r)
registerTimeline(r) registerTimeline(r)
@@ -41,6 +40,10 @@ func Register(r *httpapi.Router, uploader *upload.Uploader) {
registerPet(r) registerPet(r)
registerAdmin(r) registerAdmin(r)
registerStorage(r) registerStorage(r)
r.Handle("announcement", announcement)
r.Handle("admin_announcement", adminAnnouncement)
r.Handle("account_deletion_preview", accountDeletionPreview)
r.Handle("account_delete", accountDelete)
} }
func now() string { return time.Now().Format("2006-01-02 15:04:05") } func now() string { return time.Now().Format("2006-01-02 15:04:05") }
@@ -72,48 +75,38 @@ func rows(r []store.Row) []store.Row {
} }
// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id // generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id
// generateUserId uses a PostgreSQL sequence; simultaneous registrations cannot collide.
func generateUserId(db store.Querier) (string, error) { func generateUserId(db store.Querier) (string, error) {
row, err := db.QueryOne("SELECT `value` FROM settings WHERE `key` = 'last_user_id'") row, err := db.QueryOne("SELECT nextval('past_user_display_id') AS display_id")
if err != nil { if err != nil {
return "", err return "", err
} }
var next int64 if row == nil {
if row != nil && row.Int("value") >= 520001 { return "", fmt.Errorf("用户编号生成失败")
next = row.Int("value") + 1
} else {
mx, err := db.QueryOne("SELECT MAX(CAST(user_id AS SIGNED)) as max_id FROM users WHERE user_id LIKE '520%'")
if err != nil {
return "", err
}
if mx != nil && mx.Int("max_id") != 0 {
next = mx.Int("max_id") + 1
} else {
next = 520001
}
} }
if _, err := db.Exec("REPLACE INTO settings (`key`, `value`, updated_at) VALUES ('last_user_id', ?, CURRENT_TIMESTAMP)", fmt.Sprint(next)); err != nil { return row.Str("display_id"), nil
return "", err
}
return fmt.Sprint(next), nil
} }
// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT) // generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT)
func generateCoupleSpaceId(coupleID int64) string { func generateCoupleSpaceId(coupleID string) string {
return fmt.Sprintf("CP%06d", coupleID) return "CP" + strings.ReplaceAll(coupleID, "-", "")
} }
// logActivity 对应 logActivity(),返回 activity id // logActivity 对应 logActivity(),返回 activity id
func logActivity(db store.Querier, coupleID, userID int64, actionType, description, detail string) int64 { func logActivity(db store.Querier, coupleID, userID string, actionType, description, detail string) string {
if coupleID == "" {
return ""
}
id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())", id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, detail) coupleID, userID, actionType, description, detail)
return id return id
} }
// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空 // coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空
func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 { func coupleUserIds(c *httpapi.Ctx, coupleID string) []string {
if coupleID == 0 { if coupleID == "" {
coupleID = c.CoupleID() coupleID = c.CoupleID()
if coupleID == 0 { if coupleID == "" {
return nil return nil
} }
} }
@@ -121,28 +114,28 @@ func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 {
if row == nil { if row == nil {
return nil return nil
} }
return []int64{row.Int("user1_id"), row.Int("user2_id")} return []string{row.Str("user1_id"), row.Str("user2_id")}
} }
// partnerUserId 对应 getPartnerUserId($userId);无则 0 // partnerUserId 对应 getPartnerUserId($userId);无则 0
func partnerUserId(db store.Querier, userID int64) int64 { func partnerUserId(db store.Querier, userID string) string {
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID) u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID)
if u == nil || u.Int("couple_id") == 0 { if u == nil || u.Str("couple_id") == "" {
return 0 return ""
} }
cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id")) cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Str("couple_id"))
if cp == nil { if cp == nil {
return 0 return ""
} }
if cp.Int("user1_id") == userID { if cp.Str("user1_id") == userID {
return cp.Int("user2_id") return cp.Str("user2_id")
} }
return cp.Int("user1_id") return cp.Str("user1_id")
} }
// userOpenId 对应 getUserOpenId($uid) // userOpenId 对应 getUserOpenId($uid)
func userOpenId(db store.Querier, uid int64) string { func userOpenId(db store.Querier, uid string) string {
if uid == 0 { if uid == "" {
return "" return ""
} }
row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid) row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid)
@@ -153,7 +146,7 @@ func userOpenId(db store.Querier, uid int64) string {
} }
// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料 // executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料
func executeUnbind(db store.Querier, cid int64) { func executeUnbind(db store.Querier, cid string) {
db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid) db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid)
db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid) db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid)
db.Exec("DELETE FROM songs WHERE couple_id = ?", cid) db.Exec("DELETE FROM songs WHERE couple_id = ?", cid)
@@ -161,11 +154,7 @@ func executeUnbind(db store.Querier, cid int64) {
db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid) db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid)
albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid) albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums { for _, a := range albums {
photos, _ := db.Query("SELECT path FROM photos WHERE album_id = ?", a.Int("id")) db.Exec("DELETE FROM photos WHERE album_id = ?", a.Str("id"))
for _, p := range photos {
up.Remove(p.Str("path"))
}
db.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id"))
} }
db.Exec("DELETE FROM albums WHERE couple_id = ?", cid) db.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid) db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid)
@@ -173,7 +162,7 @@ func executeUnbind(db store.Querier, cid int64) {
if len(users) > 0 { if len(users) > 0 {
ids := make([]any, 0, len(users)) ids := make([]any, 0, len(users))
for _, u := range users { for _, u := range users {
ids = append(ids, u.Int("id")) ids = append(ids, u.Str("id"))
} }
ph := placeholders(len(ids)) ph := placeholders(len(ids))
args := append(append([]any{}, ids...), ids...) args := append(append([]any{}, ids...), ids...)
@@ -182,18 +171,11 @@ func executeUnbind(db store.Querier, cid int64) {
} }
db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid) db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid) db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
sp, _ := db.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if sp != nil && sp.Str("couple_space_id") != "" {
dir := filepath.Join(up.Dir, "couples", sp.Str("couple_space_id"))
if st, err := os.Stat(dir); err == nil && st.IsDir() {
os.RemoveAll(dir)
}
}
db.Exec("DELETE FROM couples WHERE id = ?", cid) db.Exec("DELETE FROM couples WHERE id = ?", cid)
} }
// createDefaultAlbums 对应 createDefaultAlbums() // createDefaultAlbums 对应 createDefaultAlbums()
func createDefaultAlbums(db store.Querier, coupleID, userID int64) { func createDefaultAlbums(db store.Querier, coupleID, userID string) {
defaults := [][2]string{ defaults := [][2]string{
{"日常甜蜜", "我们的小日常"}, {"日常甜蜜", "我们的小日常"},
{"旅行记录", "一起走过的风景"}, {"旅行记录", "一起走过的风景"},
@@ -430,3 +412,10 @@ func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any {
// randInt 对应 random_int(min, max) // randInt 对应 random_int(min, max)
func randInt(min, max int) int { return min + rand.Intn(max-min+1) } func randInt(min, max int) int { return min + rand.Intn(max-min+1) }
func nullableCoupleID(id string) any {
if id == "" {
return nil
}
return id
}
+52 -54
View File
@@ -72,7 +72,7 @@ func generateInviteLink(c *httpapi.Ctx) error {
} }
uid := c.UID() uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user != nil && user.Int("couple_id") != 0 { if user != nil && user.Str("couple_id") != "" {
return c.Err(400, "您已绑定情侣") return c.Err(400, "您已绑定情侣")
} }
if ex, _ := c.DB.QueryOne("SELECT code FROM invite_links WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", uid); ex != nil { if ex, _ := c.DB.QueryOne("SELECT code FROM invite_links WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", uid); ex != nil {
@@ -127,7 +127,7 @@ func inviteByPhone(c *httpapi.Ctx) error {
return c.Err(400, msg) return c.Err(400, msg)
} }
me, _ := c.DB.QueryOne("SELECT username, couple_id FROM users WHERE id = ?", uid) me, _ := c.DB.QueryOne("SELECT username, couple_id FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 { if me != nil && me.Str("couple_id") != "" {
return c.Err(400, "您已绑定情侣") return c.Err(400, "您已绑定情侣")
} }
if me != nil && me.Str("username") == target { if me != nil && me.Str("username") == target {
@@ -137,13 +137,13 @@ func inviteByPhone(c *httpapi.Ctx) error {
if t == nil { if t == nil {
return c.Err(400, "该用户不存在") return c.Err(400, "该用户不存在")
} }
if t.Int("couple_id") != 0 { if t.Str("couple_id") != "" {
return c.Err(400, "对方已绑定情侣") return c.Err(400, "对方已绑定情侣")
} }
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil { if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Str("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应") return c.Err(400, "已发送过邀请,等待对方回应")
} }
if _, err := c.DB.Exec("INSERT INTO invitations (from_user_id, to_user_id, to_phone, message, type) VALUES (?, ?, ?, ?, 'username')", uid, t.Int("id"), target, message); err != nil { if _, err := c.DB.Exec("INSERT INTO invitations (from_user_id, to_user_id, to_phone, message, type) VALUES (?, ?, ?, ?, 'username')", uid, t.Str("id"), target, message); err != nil {
return err return err
} }
return c.OK(M{"success": true, "msg": "邀请已发送"}) return c.OK(M{"success": true, "msg": "邀请已发送"})
@@ -163,7 +163,7 @@ func invitePartner(c *httpapi.Ctx) error {
return c.Err(400, "ID格式不正确,请输入6位数字ID") return c.Err(400, "ID格式不正确,请输入6位数字ID")
} }
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender FROM users WHERE id = ?", uid) me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 { if me != nil && me.Str("couple_id") != "" {
return c.Err(400, "您已绑定情侣") return c.Err(400, "您已绑定情侣")
} }
if me != nil && me.Str("user_id") == targetID { if me != nil && me.Str("user_id") == targetID {
@@ -176,13 +176,13 @@ func invitePartner(c *httpapi.Ctx) error {
if t == nil { if t == nil {
return c.Err(400, "该ID不存在,请确认对方ID是否正确") return c.Err(400, "该ID不存在,请确认对方ID是否正确")
} }
if t.Int("couple_id") != 0 { if t.Str("couple_id") != "" {
return c.Err(400, "对方已绑定情侣") return c.Err(400, "对方已绑定情侣")
} }
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil { if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Str("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应") return c.Err(400, "已发送过邀请,等待对方回应")
} }
id, err := c.DB.Insert("INSERT INTO invitations (from_user_id, to_user_id, message, type) VALUES (?, ?, ?, 'id')", uid, t.Int("id"), message) id, err := c.DB.Insert("INSERT INTO invitations (from_user_id, to_user_id, message, type) VALUES (?, ?, ?, 'id')", uid, t.Str("id"), message)
if err != nil { if err != nil {
return err return err
} }
@@ -192,9 +192,9 @@ func invitePartner(c *httpapi.Ctx) error {
func myInvitations(c *httpapi.Ctx) error { func myInvitations(c *httpapi.Ctx) error {
uid := c.UID() uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
var coupleID int64 var coupleID string
if user != nil { if user != nil {
coupleID = user.Int("couple_id") coupleID = user.Str("couple_id")
} }
received, err := c.DB.Query("SELECT i.*, u.username as from_username, u.nickname as from_nickname, u.avatar as from_avatar FROM invitations i JOIN users u ON i.from_user_id = u.id WHERE i.to_user_id = ? ORDER BY i.created_at DESC", uid) received, err := c.DB.Query("SELECT i.*, u.username as from_username, u.nickname as from_nickname, u.avatar as from_avatar FROM invitations i JOIN users u ON i.from_user_id = u.id WHERE i.to_user_id = ? ORDER BY i.created_at DESC", uid)
if err != nil { if err != nil {
@@ -212,32 +212,30 @@ func myInvitations(c *httpapi.Ctx) error {
} }
// bindCouple 创建情侣关系(事务):couples 记录、空间 ID、双方 couple_id;返回 coupleId // bindCouple 创建情侣关系(事务):couples 记录、空间 ID、双方 couple_id;返回 coupleId
func bindCouple(c *httpapi.Ctx, user1, user2 int64, extra func(tx *store.Tx) error) (int64, error) { func bindCouple(c *httpapi.Ctx, user1, user2 string, extra func(tx *store.Tx) error) (string, error) {
tx, err := c.DB.Begin() tx, err := c.DB.Begin()
if err != nil { if err != nil {
return 0, err return "", err
} }
defer tx.Rollback() defer tx.Rollback()
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2) coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2)
if err != nil { if err != nil {
return 0, err return "", err
} }
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", generateCoupleSpaceId(coupleID), coupleID); err != nil { if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", generateCoupleSpaceId(coupleID), coupleID); err != nil {
return 0, err return "", err
} }
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, user1, user2); err != nil { if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, user1, user2); err != nil {
return 0, err return "", err
} }
if extra != nil { if extra != nil {
if err := extra(tx); err != nil { if err := extra(tx); err != nil {
return 0, err return "", err
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return 0, err return "", err
} }
ensureUserDirs(user1)
ensureUserDirs(user2)
return coupleID, nil return coupleID, nil
} }
@@ -246,7 +244,7 @@ func respondInvitation(c *httpapi.Ctx) error {
return err return err
} }
uid := c.UID() uid := c.UID()
invID := c.InInt("invitation_id") invID := c.InTrim("invitation_id")
accept := c.InBool("accept") accept := c.InBool("accept")
inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid) inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid)
if err != nil { if err != nil {
@@ -255,10 +253,10 @@ func respondInvitation(c *httpapi.Ctx) error {
if inv == nil { if inv == nil {
return c.Err(404, "邀请不存在或已处理") return c.Err(404, "邀请不存在或已处理")
} }
fromID := inv.Int("from_user_id") fromID := inv.Str("from_user_id")
rs, _ := c.DB.Query("SELECT couple_id FROM users WHERE id IN (?, ?)", uid, fromID) rs, _ := c.DB.Query("SELECT couple_id FROM users WHERE id IN (?, ?)", uid, fromID)
for _, r := range rs { for _, r := range rs {
if r.Int("couple_id") != 0 { if r.Str("couple_id") != "" {
return c.Err(400, "已绑定情侣,无法接受") return c.Err(400, "已绑定情侣,无法接受")
} }
} }
@@ -295,14 +293,14 @@ func acceptShareInvite(c *httpapi.Ctx) error {
return c.Err(400, "缺少邀请人ID") return c.Err(400, "缺少邀请人ID")
} }
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender, nickname FROM users WHERE id = ?", uid) me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender, nickname FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 { if me != nil && me.Str("couple_id") != "" {
return c.Err(400, "您已绑定情侣") return c.Err(400, "您已绑定情侣")
} }
inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID) inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID)
if inviter == nil { if inviter == nil {
return c.Err(400, "邀请人不存在") return c.Err(400, "邀请人不存在")
} }
if inviter.Int("couple_id") != 0 { if inviter.Str("couple_id") != "" {
return c.Err(400, "邀请人已绑定情侣") return c.Err(400, "邀请人已绑定情侣")
} }
if me != nil && me.Str("user_id") == inviter.Str("user_id") { if me != nil && me.Str("user_id") == inviter.Str("user_id") {
@@ -311,10 +309,10 @@ func acceptShareInvite(c *httpapi.Ctx) error {
if me != nil && me.Str("gender") != "" && inviter.Str("gender") != "" && me.Str("gender") == inviter.Str("gender") { if me != nil && me.Str("gender") != "" && inviter.Str("gender") != "" && me.Str("gender") == inviter.Str("gender") {
return c.Err(400, "双方性别一致,仅支持异性绑定") return c.Err(400, "双方性别一致,仅支持异性绑定")
} }
inviterID := inviter.Int("id") inviterID := inviter.Str("id")
existing, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE ((from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)) AND status = 'pending'", uid, inviterID, inviterID, uid) existing, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE ((from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)) AND status = 'pending'", uid, inviterID, inviterID, uid)
if existing != nil { if existing != nil {
c.DB.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", existing.Int("id")) c.DB.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", existing.Str("id"))
} }
coupleID, err := bindCouple(c, uid, inviterID, func(tx *store.Tx) error { coupleID, err := bindCouple(c, uid, inviterID, func(tx *store.Tx) error {
if existing == nil { if existing == nil {
@@ -341,7 +339,7 @@ func acceptShareInvite(c *httpapi.Ctx) error {
func coupleInfo(c *httpapi.Ctx) error { func coupleInfo(c *httpapi.Ctx) error {
cid := c.CoupleID() cid := c.CoupleID()
uid := c.UID() uid := c.UID()
if cid == 0 { if cid == "" {
user, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, created_at FROM users WHERE id = ?", uid) user, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, created_at FROM users WHERE id = ?", uid)
regDate := today() regDate := today()
if user != nil { if user != nil {
@@ -373,7 +371,7 @@ func coupleInfo(c *httpapi.Ctx) error {
if info == nil { if info == nil {
info = store.Row{} info = store.Row{}
} }
partners, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, region, location, lat, lng FROM users WHERE couple_id = ? ORDER BY id ASC", cid) partners, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, region, location, lat, lng FROM users WHERE couple_id = ? ORDER BY CASE WHEN id = (SELECT user1_id FROM couples WHERE id = ?) THEN 0 ELSE 1 END", cid, cid)
if err != nil { if err != nil {
return err return err
} }
@@ -395,7 +393,7 @@ func coupleInfo(c *httpapi.Ctx) error {
} }
var partner store.Row var partner store.Row
for _, p := range partners { for _, p := range partners {
if p.Int("id") != uid { if p.Str("id") != uid {
partner = p partner = p
} }
} }
@@ -429,7 +427,7 @@ func updateCoupleInfo(c *httpapi.Ctx) error {
return err return err
} }
cid := c.CoupleID() cid := c.CoupleID()
if cid == 0 { if cid == "" {
return c.Err(400, "请先绑定情侣") return c.Err(400, "请先绑定情侣")
} }
uid := c.UID() uid := c.UID()
@@ -445,18 +443,18 @@ func updateCoupleInfo(c *httpapi.Ctx) error {
return c.OK(M{"success": true}) return c.OK(M{"success": true})
} }
func userCoupleID(c *httpapi.Ctx, uid int64) int64 { func userCoupleID(c *httpapi.Ctx, uid string) string {
u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if u == nil { if u == nil {
return 0 return ""
} }
return u.Int("couple_id") return u.Str("couple_id")
} }
// withBoundCouple serializes all lifecycle transitions with the expiry worker. // withBoundCouple serializes all lifecycle transitions with the expiry worker.
func withBoundCouple(c *httpapi.Ctx, next func(*store.Tx, int64) error) error { func withBoundCouple(c *httpapi.Ctx, next func(*store.Tx, string) error) error {
cid := c.CoupleID() cid := c.CoupleID()
if cid == 0 { if cid == "" {
return c.Err(400, "未绑定情侣") return c.Err(400, "未绑定情侣")
} }
tx, err := c.DB.Begin() tx, err := c.DB.Begin()
@@ -484,8 +482,8 @@ func unbindRequest(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error { err := withBoundCouple(c, func(tx *store.Tx, cid string) error {
if _, err := tx.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil { if _, err := tx.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=(NOW() - INTERVAL '72 hour')", cid); err != nil {
return err return err
} }
existing, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status IN ('pending','cooling') FOR UPDATE", cid) existing, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status IN ('pending','cooling') FOR UPDATE", cid)
@@ -508,8 +506,8 @@ func unbindRespond(c *httpapi.Ctx) error {
return err return err
} }
agree := c.InBool("agree") agree := c.InBool("agree")
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error { err := withBoundCouple(c, func(tx *store.Tx, cid string) error {
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status='pending' AND requester_id!=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR) FOR UPDATE", cid, c.UID()) req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status='pending' AND requester_id!=? AND created_at>(NOW() - INTERVAL '72 hour') FOR UPDATE", cid, c.UID())
if err != nil { if err != nil {
return err return err
} }
@@ -517,9 +515,9 @@ func unbindRespond(c *httpapi.Ctx) error {
return c.Err(409, "没有有效的待确认申请") return c.Err(409, "没有有效的待确认申请")
} }
if agree { if agree {
_, err = tx.Exec("UPDATE unbind_requests SET status='cooling',responded_at=NOW(),cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR) WHERE id=?", req.Int("id")) _, err = tx.Exec("UPDATE unbind_requests SET status='cooling',responded_at=NOW(),cooling_until=(NOW() + INTERVAL '72 hours') WHERE id=?", req.Str("id"))
} else { } else {
_, err = tx.Exec("UPDATE unbind_requests SET status='rejected',responded_at=NOW() WHERE id=?", req.Int("id")) _, err = tx.Exec("UPDATE unbind_requests SET status='rejected',responded_at=NOW() WHERE id=?", req.Str("id"))
} }
return err return err
}) })
@@ -532,15 +530,15 @@ func unbindCancel(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
err := withBoundCouple(c, func(tx *store.Tx, cid int64) error { err := withBoundCouple(c, func(tx *store.Tx, cid string) error {
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND ((status='pending' AND requester_id=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR)) OR (status='cooling' AND cooling_until>NOW())) ORDER BY id DESC LIMIT 1 FOR UPDATE", cid, c.UID()) req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND ((status='pending' AND requester_id=? AND created_at>(NOW() - INTERVAL '72 hour')) OR (status='cooling' AND cooling_until>NOW())) ORDER BY created_at DESC, id DESC LIMIT 1 FOR UPDATE", cid, c.UID())
if err != nil { if err != nil {
return err return err
} }
if req == nil { if req == nil {
return c.Err(409, "没有可取消的申请,或冷静期已结束") return c.Err(409, "没有可取消的申请,或冷静期已结束")
} }
_, err = tx.Exec("UPDATE unbind_requests SET status='cancelled',cancelled_at=NOW(),cancelled_by=? WHERE id=?", c.UID(), req.Int("id")) _, err = tx.Exec("UPDATE unbind_requests SET status='cancelled',cancelled_at=NOW(),cancelled_by=? WHERE id=?", c.UID(), req.Str("id"))
return err return err
}) })
if err != nil { if err != nil {
@@ -554,29 +552,29 @@ func unbindStatus(c *httpapi.Ctx) error {
} }
cid := c.CoupleID() cid := c.CoupleID()
uid := c.UID() uid := c.UID()
if cid == 0 { if cid == "" {
cp, err := c.DB.QueryOne("SELECT id FROM couples WHERE user1_id=? OR user2_id=? ORDER BY id DESC LIMIT 1", uid, uid) cp, err := c.DB.QueryOne("SELECT id FROM couples WHERE user1_id=? OR user2_id=? ORDER BY created_at DESC, id DESC LIMIT 1", uid, uid)
if err != nil { if err != nil {
return err return err
} }
if cp != nil { if cp != nil {
cid = cp.Int("id") cid = cp.Str("id")
} }
} }
if cid == 0 { if cid == "" {
return c.OK(M{"status": "none", "couple_id": nil}) return c.OK(M{"status": "none", "couple_id": nil})
} }
if _, err := c.DB.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil { if _, err := c.DB.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=(NOW() - INTERVAL '72 hour')", cid); err != nil {
return err return err
} }
req, err := c.DB.QueryOne("SELECT id,requester_id,status,created_at,responded_at,cooling_until,completed_at,cancelled_at,cancelled_by,GREATEST(0,TIMESTAMPDIFF(SECOND,NOW(),CASE WHEN status='cooling' THEN cooling_until ELSE DATE_ADD(created_at,INTERVAL 72 HOUR) END)) AS remaining_seconds FROM unbind_requests WHERE couple_id=? ORDER BY id DESC LIMIT 1", cid) req, err := c.DB.QueryOne("SELECT id,requester_id,status,created_at,responded_at,cooling_until,completed_at,cancelled_at,cancelled_by,GREATEST(0,CAST(EXTRACT(EPOCH FROM (CASE WHEN status='cooling' THEN cooling_until ELSE (created_at + INTERVAL '72 hours') END - NOW())) AS BIGINT)) AS remaining_seconds FROM unbind_requests WHERE couple_id=? ORDER BY created_at DESC, id DESC LIMIT 1", cid)
if err != nil { if err != nil {
return err return err
} }
if req == nil { if req == nil {
return c.OK(M{"status": "none", "couple_id": cid}) return c.OK(M{"status": "none", "couple_id": cid})
} }
req["is_requester"] = req.Int("requester_id") == uid req["is_requester"] = req.Str("requester_id") == uid
req["couple_id"] = cid req["couple_id"] = cid
req["data_deleted"] = 0 req["data_deleted"] = 0
if req.Str("status") == "completed" { if req.Str("status") == "completed" {
@@ -588,7 +586,7 @@ func unbindStatus(c *httpapi.Ctx) error {
req["data_deleted"] = cp.Int("data_deleted") req["data_deleted"] = cp.Int("data_deleted")
} }
} }
owner, err := c.DB.QueryOne("SELECT nickname FROM users WHERE id=?", req.Int("requester_id")) owner, err := c.DB.QueryOne("SELECT nickname FROM users WHERE id=?", req.Str("requester_id"))
if err != nil { if err != nil {
return err return err
} }
@@ -604,7 +602,7 @@ func unbindCouple(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil { if err := c.DB.AdvanceUnbindings(); err != nil {
return err return err
} }
if c.CoupleID() != 0 { if c.CoupleID() != "" {
return c.Err(409, "冷静期结束后将自动解绑,不能提前解除") return c.Err(409, "冷静期结束后将自动解绑,不能提前解除")
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
+34 -41
View File
@@ -9,8 +9,8 @@ import (
"past/internal/store" "past/internal/store"
) )
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义 // mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://we.sundynix.cn/backend/'),MEDIA_BASE 未定义
const mediaBase = "https://past.sundynix.cn/backend/" const mediaBase = "https://we.sundynix.cn/backend/"
var tagRe = regexp.MustCompile(`<[^>]*>`) var tagRe = regexp.MustCompile(`<[^>]*>`)
@@ -31,7 +31,7 @@ func avatarURL(avatar string) string {
if avatar == "" { if avatar == "" {
return "" return ""
} }
if strings.HasPrefix(avatar, "http") { if strings.HasPrefix(avatar, "http") || strings.HasPrefix(avatar, "/api/files/") {
return avatar return avatar
} }
return mediaBase + avatar return mediaBase + avatar
@@ -58,7 +58,7 @@ func registerDiary(r *httpapi.Router) {
} }
func diaryList(c *httpapi.Ctx) error { func diaryList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := albumUserIds(c)
if len(ids) == 0 { if len(ids) == 0 {
demo := demoData("diary") demo := demoData("diary")
return c.OK(M{"items": demo, "total": len(demo)}) return c.OK(M{"items": demo, "total": len(demo)})
@@ -109,10 +109,7 @@ func diaryAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid, err := requireCoupleID(c) cid := c.CoupleID()
if err != nil {
return err
}
uid := c.UID() uid := c.UID()
diaryDate := diaryDateOrFallback(c.In("diary_date"), today()) diaryDate := diaryDateOrFallback(c.In("diary_date"), today())
images := inOr(c, "images", []any{}) images := inOr(c, "images", []any{})
@@ -129,7 +126,7 @@ func diaryAdd(c *httpapi.Ctx) error {
return err return err
} }
id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid) nullableCoupleID(cid), title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid)
if err != nil { if err != nil {
return err return err
} }
@@ -145,7 +142,7 @@ func diaryAdd(c *httpapi.Ctx) error {
desc += fmt.Sprintf(",上传了%d张图片", imgCount) desc += fmt.Sprintf(",上传了%d张图片", imgCount)
} }
actID := logActivity(c.DB, cid, uid, "diary_create", desc, "") actID := logActivity(c.DB, cid, uid, "diary_create", desc, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
displayTitle := "一篇新日记" displayTitle := "一篇新日记"
if store.Truthy(titleS) { if store.Truthy(titleS) {
displayTitle = titleS displayTitle = titleS
@@ -166,10 +163,13 @@ func diaryUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { if item, err := c.DB.QueryOne("SELECT id FROM diaries WHERE id=? AND created_by IN (?,?)", c.In("id"), ids[0], ids[1]); err != nil {
return err return err
} else if item == nil {
return c.Err(404, "日记不存在或无权编辑")
} }
var err error
images := inOr(c, "images", []any{}) images := inOr(c, "images", []any{})
// 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期; // 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期;
// 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。 // 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。
@@ -195,10 +195,7 @@ func diaryDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err return err
} }
@@ -214,14 +211,12 @@ func countOf(c *httpapi.Ctx, sql string, args ...any) int64 {
} }
func diaryDetail(c *httpapi.Ctx) error { func diaryDetail(c *httpapi.Ctx) error {
id := c.QInt("id") id := c.Q("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少日记ID") return c.Err(400, "缺少日记ID")
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err
}
uid := c.UID() uid := c.UID()
item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1]) item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil { if err != nil {
@@ -243,14 +238,12 @@ func diaryDetail(c *httpapi.Ctx) error {
} }
func diaryCommentList(c *httpapi.Ctx) error { func diaryCommentList(c *httpapi.Ctx) error {
diaryID := c.QInt("diary_id") diaryID := c.Q("diary_id")
if diaryID == 0 { if diaryID == "" {
return c.Err(400, "缺少日记ID") return c.Err(400, "缺少日记ID")
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err
}
suffix, window := listWindow(c) suffix, window := listWindow(c)
comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...) comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...)
if err != nil { if err != nil {
@@ -271,12 +264,12 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
return err return err
} }
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
diaryID := c.InInt("diary_id") diaryID := c.InTrim("diary_id")
content := c.InTrim("content") content := c.InTrim("content")
if diaryID == 0 { if diaryID == "" {
return c.Err(400, "缺少日记ID") return c.Err(400, "缺少日记ID")
} }
if !store.Truthy(content) { if !store.Truthy(content) {
@@ -289,10 +282,10 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
if diary == nil { if diary == nil {
return c.Err(404, "日记不存在") return c.Err(404, "日记不存在")
} }
parentID := c.InInt("parent_id") parentID := c.InTrim("parent_id")
var replyToUserID any var replyToUserID any
if c.InHas("reply_to_user_id") { if c.InHas("reply_to_user_id") {
replyToUserID = c.InInt("reply_to_user_id") replyToUserID = c.InTrim("reply_to_user_id")
} }
commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)", commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)",
diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username")) diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username"))
@@ -306,14 +299,14 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
out = comment out = comment
} }
// 通知对象:优先被回复的人,否则日记作者;自己评论自己不通知 // 通知对象:优先被回复的人,否则日记作者;自己评论自己不通知
var notifyTo int64 var notifyTo string
if replyToUserID != nil { if replyToUserID != nil {
notifyTo = store.ToInt(replyToUserID) notifyTo = store.ToStr(replyToUserID)
} }
if notifyTo == 0 { if notifyTo == "" {
notifyTo = diary.Int("created_by") notifyTo = diary.Str("created_by")
} }
if notifyTo != 0 && notifyTo != uid { if notifyTo != "" && notifyTo != uid {
actID := logActivity(c.DB, cid, uid, "diary_comment_add", "评论了你的日记", "") actID := logActivity(c.DB, cid, uid, "diary_comment_add", "评论了你的日记", "")
c.App.Wechat.SendSubscribeMessage(notifyTo, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(notifyTo, "notify", map[string]string{
"thing1": "日记有新评论 💬", "thing1": "日记有新评论 💬",
@@ -330,11 +323,11 @@ func diaryCommentDelete(c *httpapi.Ctx) error {
return err return err
} }
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
commentID := c.InInt("id") commentID := c.InTrim("id")
if commentID == 0 { if commentID == "" {
return c.Err(400, "缺少评论ID") return c.Err(400, "缺少评论ID")
} }
if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil { if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil {
@@ -19,7 +19,7 @@ func TestPagedListsBoundQueriesAndKeepMemberScope(t *testing.T) {
{"songs", "/api/songsList", "songs", songsList, []any{int64(1), int64(3), int64(20), int64(20)}, nil}, {"songs", "/api/songsList", "songs", songsList, []any{int64(1), int64(3), int64(20), int64(20)}, nil},
{"albums", "/api/albumList", "albums", albumList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM photos p JOIN albums a", Columns: []string{"c"}, Rows: [][]driver.Value{{int64(83)}}}}}, {"albums", "/api/albumList", "albums", albumList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM photos p JOIN albums a", Columns: []string{"c"}, Rows: [][]driver.Value{{int64(83)}}}}},
{"photos", "/api/photos?album_id=9", "photos", photos, []any{int64(1), int64(3), "9", int64(20), int64(20)}, nil}, {"photos", "/api/photos?album_id=9", "photos", photos, []any{int64(1), int64(3), "9", int64(20), int64(20)}, nil},
{"timeline", "/api/timelineList", "timeline", timelineList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "event_date >= CURRENT_DATE", Columns: []string{"id", "event_date"}, Rows: [][]driver.Value{{int64(50), "2026-12-01"}}}}}, {"timeline", "/api/timelineList", "timeline", timelineList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "event_date >= TO_CHAR(CURRENT_DATE,'YYYY-MM-DD')", Columns: []string{"id", "event_date"}, Rows: [][]driver.Value{{int64(50), "2026-12-01"}}}}},
{"checkins", "/api/checkinList", "checkins", checkinList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM checkin_logs WHERE couple_id = ?", Args: []any{int64(22)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(90)}}}}}, {"checkins", "/api/checkinList", "checkins", checkinList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM checkin_logs WHERE couple_id = ?", Args: []any{int64(22)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(90)}}}}},
{"comments", "/api/diaryCommentList?diary_id=9", "diary_comments", diaryCommentList, []any{int64(9), int64(1), int64(3), int64(20), int64(20)}, nil}, {"comments", "/api/diaryCommentList?diary_id=9", "diary_comments", diaryCommentList, []any{int64(9), int64(1), int64(3), int64(20), int64(20)}, nil},
} { } {
+41 -53
View File
@@ -22,8 +22,12 @@ func registerBg(r *httpapi.Router) {
// uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败) // uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败)
func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error { func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error {
if res.Path != "" { if res.Path != "" {
c.UploadResult = &res
return onSuccess(res.Path) return onSuccess(res.Path)
} }
if res.Code == upload.ErrStorageQuotaExceeded {
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
}
if strings.Contains(res.Error, "违规") { if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed) return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
} }
@@ -42,27 +46,13 @@ func uploadGeneric(c *httpapi.Ctx) error {
return c.Err(400, "无效的上传目录") return c.Err(400, "无效的上传目录")
} }
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
} }
prefix := upload.UserFilePath(uid, subDir)
var pendingBytes int64
for _, f := range []string{"file", "avatar", "photo", "image"} {
if fh := c.File(f); fh != nil {
pendingBytes = fh.Size
break
}
}
if pendingBytes == 0 {
pendingBytes = int64(len(c.Body()))
}
if err := CheckStorageQuota(c, pendingBytes); err != nil {
return err
}
var res *upload.Result var res *upload.Result
for _, f := range []string{"file", "avatar", "photo", "image"} { for _, f := range []string{"file", "avatar", "photo", "image"} {
if fh := c.File(f); fh != nil { if fh := c.File(f); fh != nil {
r := up.Handle(fh, "", prefix) r := up.HandleFor(fh, c.UID())
res = &r res = &r
break break
} }
@@ -70,7 +60,7 @@ func uploadGeneric(c *httpapi.Ctx) error {
if res == nil { if res == nil {
// 兼容某些 Android 端:原始请求体 // 兼容某些 Android 端:原始请求体
if raw := c.Body(); len(raw) > 100 { if raw := c.Body(); len(raw) > 100 {
r := up.HandleBytes(raw, "upload_"+time.Now().Format("20060102150405")+".jpg", "", prefix) r := up.SaveRawFor(raw, "jpg", c.UID())
res = &r res = &r
} }
} }
@@ -83,7 +73,7 @@ func uploadGeneric(c *httpapi.Ctx) error {
} }
func settingValue(c *httpapi.Ctx, key string) (string, bool) { func settingValue(c *httpapi.Ctx, key string) (string, bool) {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
if row == nil { if row == nil {
return "", false return "", false
} }
@@ -91,19 +81,26 @@ func settingValue(c *httpapi.Ctx, key string) (string, bool) {
} }
func setSetting(c *httpapi.Ctx, key string, value any) error { func setSetting(c *httpapi.Ctx, key string, value any) error {
_, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", key, value, value) _, err := c.DB.Exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value = ?", key, value, value)
return err return err
} }
func bgKeys(cid int64) (string, string) { func bgKeys(cid string) (string, string) {
if cid != 0 { if cid != "" {
return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid) return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid)
} }
return "bg_global", "bgver_global" return "bg_global", "bgver_global"
} }
func personalBgKeys(c *httpapi.Ctx) (string, string) {
if c.CoupleID() != "" {
return bgKeys(c.CoupleID())
}
return "bg_user_" + c.UID(), "bgver_user_" + c.UID()
}
func getBgConfig(c *httpapi.Ctx) error { func getBgConfig(c *httpapi.Ctx) error {
bgKey, verKey := bgKeys(c.CoupleID()) bgKey, verKey := personalBgKeys(c)
img, _ := settingValue(c, bgKey) img, _ := settingValue(c, bgKey)
ver, _ := settingValue(c, verKey) ver, _ := settingValue(c, verKey)
return c.OK(M{"image": img, "version": store.ToInt(ver)}) return c.OK(M{"image": img, "version": store.ToInt(ver)})
@@ -113,12 +110,7 @@ func setBgImage(c *httpapi.Ctx) error {
if c.Method != "POST" { if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
} }
cid := c.CoupleID() bgKey, verKey := personalBgKeys(c)
if cid == 0 {
return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound)
}
couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers")
bgKey, verKey := bgKeys(cid)
nextVersion := func() int64 { nextVersion := func() int64 {
if old, ok := settingValue(c, verKey); ok { if old, ok := settingValue(c, verKey); ok {
return store.ToInt(old) + 1 return store.ToInt(old) + 1
@@ -138,24 +130,19 @@ func setBgImage(c *httpapi.Ctx) error {
if fh == nil { if fh == nil {
fh = c.File("file") fh = c.File("file")
} }
var pendingBytes int64
if fh != nil {
pendingBytes = fh.Size
} else {
pendingBytes = int64(len(c.Body()))
}
if err := CheckStorageQuota(c, pendingBytes); err != nil {
return err
}
if fh != nil { if fh != nil {
version := nextVersion() version := nextVersion()
return uploadResult(c, up.Handle(fh, "", couplePath), func(path string) error { return saveBg(path, version) }) return uploadResult(c, up.HandleFor(fh, c.UID()), func(path string) error { return saveBg(path, version) })
} }
if raw := c.Body(); len(raw) > 100 { if raw := c.Body(); len(raw) > 100 {
res := up.SaveRaw(raw, "bg_", "jpg", "", couplePath) res := up.SaveRawFor(raw, "jpg", c.UID())
if res.Path != "" { if res.Path != "" {
c.UploadResult = &res
return saveBg(res.Path, nextVersion()) return saveBg(res.Path, nextVersion())
} }
if res.Code == upload.ErrStorageQuotaExceeded {
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
}
if strings.Contains(res.Error, "违规") { if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed) return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
} }
@@ -168,15 +155,8 @@ func deleteBgImage(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid, err := requireCoupleID(c) bgKey, verKey := personalBgKeys(c)
if err != nil { if _, err := c.DB.Exec("DELETE FROM settings WHERE key = ? OR key = ?", bgKey, verKey); err != nil {
return err
}
bgKey, verKey := bgKeys(cid)
if v, ok := settingValue(c, bgKey); ok && v != "" {
up.Remove(v)
}
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", bgKey, verKey); err != nil {
return err return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
@@ -190,7 +170,7 @@ func registerSite(r *httpapi.Router) {
} }
func siteInfo(c *httpapi.Ctx) error { func siteInfo(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT `key`, `value` FROM settings") rs, err := c.DB.Query("SELECT key, value FROM settings")
if err != nil { if err != nil {
return err return err
} }
@@ -257,7 +237,7 @@ func pageParams(c *httpapi.Ctx, defSize, maxSize int64) (page, pageSize, offset
} }
func activityLogList(c *httpapi.Ctx) error { func activityLogList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
return c.OK([]any{}) return c.OK([]any{})
} }
@@ -277,13 +257,16 @@ func subscribeTemplate(c *httpapi.Ctx) error {
} }
uid := c.UID() uid := c.UID()
templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id") templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id")
if templateType == "checkin" {
templateType = "notify"
}
if !store.Truthy(templateType) || !store.Truthy(templateID) { if !store.Truthy(templateType) || !store.Truthy(templateID) {
return c.Err(400, "缺少参数") return c.Err(400, "缺少参数")
} }
if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID { if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID {
return c.Err(400, "订阅模板与服务端配置不一致") return c.Err(400, "订阅模板与服务端配置不一致")
} }
if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE template_id = VALUES(template_id)", uid, templateID, templateType); err != nil { if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON CONFLICT (user_id, template_type) WHERE deleted_at IS NULL DO UPDATE SET template_id = EXCLUDED.template_id", uid, templateID, templateType); err != nil {
return err return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
@@ -294,10 +277,13 @@ func unsubscribeTemplate(c *httpapi.Ctx) error {
return err return err
} }
templateType := c.InTrim("template_type") templateType := c.InTrim("template_type")
if templateType == "checkin" {
templateType = "notify"
}
if !store.Truthy(templateType) { if !store.Truthy(templateType) {
return c.Err(400, "缺少参数") return c.Err(400, "缺少参数")
} }
if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", c.UID(), templateType); err != nil { if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type IN (?, 'checkin')", c.UID(), templateType); err != nil {
return err return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
@@ -313,7 +299,9 @@ func mySubscribeTemplates(c *httpapi.Ctx) error {
} }
out := M{} out := M{}
for _, r := range rs { for _, r := range rs {
out[r.Str("template_type")] = r["template_id"] if r.Str("template_id") == c.App.Wechat.NotifyTemplateID {
out["notify"] = r["template_id"]
}
} }
return c.OK(M{"templates": out}) return c.OK(M{"templates": out})
} }
+23 -23
View File
@@ -39,7 +39,7 @@ func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc {
// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0 // cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0
func cidOrNil(c *httpapi.Ctx) any { func cidOrNil(c *httpapi.Ctx) any {
if cid := c.CoupleID(); cid != 0 { if cid := c.CoupleID(); cid != "" {
return cid return cid
} }
return nil return nil
@@ -54,7 +54,7 @@ func coupleCond(cid any) (string, []any) {
} }
// 每人独立控制自己是否向伴侣开放同一份姨姨数据。 // 每人独立控制自己是否向伴侣开放同一份姨姨数据。
func periodVisible(c *httpapi.Ctx, uid int64) int64 { func periodVisible(c *httpapi.Ctx, uid string) int64 {
row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid) row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid)
if row == nil { if row == nil {
return 0 return 0
@@ -67,7 +67,7 @@ func emptyPeriod(visible int64) M {
} }
type periodAccess struct { type periodAccess struct {
uid, cid, partnerID int64 uid, cid, partnerID string
visible, partnerVisible int64 visible, partnerVisible int64
} }
@@ -75,11 +75,11 @@ func currentPeriodAccess(c *httpapi.Ctx) periodAccess {
uid := c.UID() uid := c.UID()
cid := c.CoupleID() cid := c.CoupleID()
access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1} access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1}
if cid != 0 { if cid != "" {
access.partnerVisible = 0 access.partnerVisible = 0
partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid) partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid)
if partner != nil { if partner != nil {
access.partnerID = partner.Int("id") access.partnerID = partner.Str("id")
access.partnerVisible = partner.Int("period_visible") access.partnerVisible = partner.Int("period_visible")
} }
} }
@@ -124,7 +124,7 @@ func periodList(c *httpapi.Ctx) error {
// 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。 // 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。
var where string var where string
var args []any var args []any
if access.cid != 0 { if access.cid != "" {
where = "couple_id = ?" where = "couple_id = ?"
args = []any{access.cid} args = []any{access.cid}
} else { } else {
@@ -167,7 +167,7 @@ func periodList(c *httpapi.Ctx) error {
cy["end_date"] = last cy["end_date"] = last
} }
} }
return c.OK(M{"cycles": cycleRows, "daily": dailyRows, "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0}) return c.OK(M{"cycles": cycleRows, "daily": dailyRows, "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != ""})
} }
func periodAdd(c *httpapi.Ctx) error { func periodAdd(c *httpapi.Ctx) error {
@@ -206,7 +206,7 @@ func periodAdd(c *httpapi.Ctx) error {
return err return err
} }
for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) { for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) {
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil { if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON CONFLICT (record_date, couple_id) WHERE deleted_at IS NULL DO UPDATE SET period = EXCLUDED.period", cid, uid, d.Format("2006-01-02")); err != nil {
return err return err
} }
} }
@@ -214,7 +214,7 @@ func periodAdd(c *httpapi.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
if periodVisible(c, uid) != 0 { if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "经期记录有更新", "thing1": "经期记录有更新",
@@ -230,9 +230,9 @@ func periodAdd(c *httpapi.Ctx) error {
// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。 // logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。
// 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。 // 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。
func logActivityErr(c *httpapi.Ctx, coupleID any, userID int64, actionType, description string) (int64, error) { func logActivityErr(c *httpapi.Ctx, coupleID any, userID string, actionType, description string) (string, error) {
if coupleID == nil { if coupleID == nil {
return 0, nil return "", nil
} }
return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())", return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, "") coupleID, userID, actionType, description, "")
@@ -244,8 +244,8 @@ func periodUpdate(c *httpapi.Ctx) error {
} }
cid := cidOrNil(c) cid := cidOrNil(c)
uid := c.UID() uid := c.UID()
id := c.InInt("id") id := c.InTrim("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少记录ID") return c.Err(400, "缺少记录ID")
} }
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
@@ -331,7 +331,7 @@ func periodUpdate(c *httpapi.Ctx) error {
continue continue
} }
} }
if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, date); err != nil { if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON CONFLICT (record_date, couple_id) WHERE deleted_at IS NULL DO UPDATE SET period = EXCLUDED.period", cid, uid, date); err != nil {
return err return err
} }
} }
@@ -347,14 +347,14 @@ func periodDelete(c *httpapi.Ctx) error {
} }
cid := cidOrNil(c) cid := cidOrNil(c)
uid := c.UID() uid := c.UID()
id := c.InInt("id") id := c.InTrim("id")
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date") startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
cond, condArgs := coupleCond(cid) cond, condArgs := coupleCond(cid)
if cid == nil { if cid == nil {
cond += " AND user_id = ?" cond += " AND user_id = ?"
condArgs = append(condArgs, uid) condArgs = append(condArgs, uid)
} }
if id != 0 { if id != "" {
delArgs := append([]any{id}, condArgs...) delArgs := append([]any{id}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil { if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err return err
@@ -379,7 +379,7 @@ func periodDelete(c *httpapi.Ctx) error {
func periodVisibleGet(c *httpapi.Ctx) error { func periodVisibleGet(c *httpapi.Ctx) error {
access := currentPeriodAccess(c) access := currentPeriodAccess(c)
return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != 0}) return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != ""})
} }
func periodVisibleSet(c *httpapi.Ctx) error { func periodVisibleSet(c *httpapi.Ctx) error {
@@ -439,7 +439,7 @@ func periodDailySave(c *httpapi.Ctx) error {
} }
} else { } else {
args := append([]any{cid, uid, recordDate}, values...) args := append([]any{cid, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE period = VALUES(period), sex = VALUES(sex), symptoms = VALUES(symptoms), mood = VALUES(mood), discharge = VALUES(discharge), temperature = VALUES(temperature), weight = VALUES(weight), note = VALUES(note), period_detail = VALUES(period_detail), updated_at = NOW()", args...); err != nil { if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (record_date, couple_id) WHERE deleted_at IS NULL DO UPDATE SET period = EXCLUDED.period, sex = EXCLUDED.sex, symptoms = EXCLUDED.symptoms, mood = EXCLUDED.mood, discharge = EXCLUDED.discharge, temperature = EXCLUDED.temperature, weight = EXCLUDED.weight, note = EXCLUDED.note, period_detail = EXCLUDED.period_detail, updated_at = NOW()", args...); err != nil {
return err return err
} }
} }
@@ -458,12 +458,12 @@ func periodDailySave(c *httpapi.Ctx) error {
} }
for _, cy := range cycles { for _, cy := range cycles {
cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"]) cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"])
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > 0", cntArgs...) cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > '0'", cntArgs...)
if err != nil { if err != nil {
return err return err
} }
if cnt != nil && cnt.Int("cnt") == 0 { if cnt != nil && cnt.Int("cnt") == 0 {
delArgs := append([]any{cy.Int("id")}, condArgs...) delArgs := append([]any{cy.Str("id")}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil { if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err return err
} }
@@ -529,7 +529,7 @@ func periodSexAdd(c *httpapi.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
if periodVisible(c, uid) != 0 { if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情", "thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情",
@@ -545,8 +545,8 @@ func periodSexDelete(c *httpapi.Ctx) error {
} }
cid := cidOrNil(c) cid := cidOrNil(c)
uid := c.UID() uid := c.UID()
id := c.InInt("id") id := c.InTrim("id")
if id == 0 { if id == "" {
return c.Err(400, "缺少记录ID") return c.Err(400, "缺少记录ID")
} }
if cid == nil { if cid == nil {
+24 -24
View File
@@ -9,32 +9,32 @@ import (
// 绑定前的姨姨记录可能双方都有。只允许一份成为情侣的当前记录; // 绑定前的姨姨记录可能双方都有。只允许一份成为情侣的当前记录;
// 在用户选择前保留两份,选择时在同一事务中删除另一份并迁入当前情侣空间。 // 在用户选择前保留两份,选择时在同一事务中删除另一份并迁入当前情侣空间。
func periodSourceState(c *httpapi.Ctx) (required bool, me, partner int64, err error) { func periodSourceState(c *httpapi.Ctx) (required bool, me, partner string, err error) {
cid, uid := c.CoupleID(), c.UID() cid, uid := c.CoupleID(), c.UID()
if cid == 0 { if cid == "" {
return false, uid, 0, nil return false, uid, "", nil
} }
couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid) couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid)
if err != nil || couple == nil { if err != nil || couple == nil {
return false, 0, 0, err return false, "", "", err
} }
if couple.Int("user1_id") == uid { if couple.Str("user1_id") == uid {
partner = couple.Int("user2_id") partner = couple.Str("user2_id")
} else if couple.Int("user2_id") == uid { } else if couple.Str("user2_id") == uid {
partner = couple.Int("user1_id") partner = couple.Str("user1_id")
} else { } else {
return false, 0, 0, fmt.Errorf("账号不属于当前情侣") return false, "", "", fmt.Errorf("账号不属于当前情侣")
} }
if couple.Int("period_source_user_id") != 0 { if couple.Str("period_source_user_id") != "" {
return false, uid, partner, nil return false, uid, partner, nil
} }
myCount, err := periodSourceCount(c.DB, uid) myCount, err := periodSourceCount(c.DB, uid)
if err != nil { if err != nil {
return false, 0, 0, err return false, "", "", err
} }
partnerCount, err := periodSourceCount(c.DB, partner) partnerCount, err := periodSourceCount(c.DB, partner)
if err != nil { if err != nil {
return false, 0, 0, err return false, "", "", err
} }
if myCount > 0 && partnerCount > 0 { if myCount > 0 && partnerCount > 0 {
return true, uid, partner, nil return true, uid, partner, nil
@@ -47,7 +47,7 @@ func periodSourceState(c *httpapi.Ctx) (required bool, me, partner int64, err er
return false, uid, partner, err return false, uid, partner, err
} }
func periodSourceCount(db store.Querier, uid int64) (int64, error) { func periodSourceCount(db store.Querier, uid string) (int64, error) {
row, err := db.QueryOne(`SELECT row, err := db.QueryOne(`SELECT
(SELECT COUNT(*) FROM period_records WHERE user_id = ? AND couple_id IS NULL) + (SELECT COUNT(*) FROM period_records WHERE user_id = ? AND couple_id IS NULL) +
(SELECT COUNT(*) FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL) + (SELECT COUNT(*) FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL) +
@@ -59,7 +59,7 @@ func periodSourceCount(db store.Querier, uid int64) (int64, error) {
return row.Int("n"), nil return row.Int("n"), nil
} }
func periodSourcePreview(db store.Querier, uid int64) (M, error) { func periodSourcePreview(db store.Querier, uid string) (M, error) {
row, err := db.QueryOne("SELECT COUNT(*) AS cycles, MAX(start_date) AS latest FROM period_records WHERE user_id = ? AND couple_id IS NULL", uid) row, err := db.QueryOne("SELECT COUNT(*) AS cycles, MAX(start_date) AS latest FROM period_records WHERE user_id = ? AND couple_id IS NULL", uid)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,9 +71,9 @@ func periodSourcePreview(db store.Querier, uid int64) (M, error) {
return M{"cycles": row.Int("cycles"), "latest": row.Str("latest"), "items": total}, nil return M{"cycles": row.Int("cycles"), "latest": row.Str("latest"), "items": total}, nil
} }
func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error { func selectPeriodSource(c *httpapi.Ctx, keepUserID string) error {
cid := c.CoupleID() cid := c.CoupleID()
if cid == 0 { if cid == "" {
return c.Err(400, "未绑定伴侣") return c.Err(400, "未绑定伴侣")
} }
tx, err := c.DB.Begin() tx, err := c.DB.Begin()
@@ -85,18 +85,18 @@ func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
if err != nil { if err != nil {
return err return err
} }
if couple == nil || (keepUserID != couple.Int("user1_id") && keepUserID != couple.Int("user2_id")) { if couple == nil || (keepUserID != couple.Str("user1_id") && keepUserID != couple.Str("user2_id")) {
return c.Err(400, "记录来源不是当前情侣成员") return c.Err(400, "记录来源不是当前情侣成员")
} }
if existing := couple.Int("period_source_user_id"); existing != 0 { if existing := couple.Str("period_source_user_id"); existing != "" {
if existing == keepUserID { if existing == keepUserID {
return nil return nil
} }
return c.Err(409, "姨姨记录来源已经选定") return c.Err(409, "姨姨记录来源已经选定")
} }
deleteUserID := couple.Int("user1_id") deleteUserID := couple.Str("user1_id")
if deleteUserID == keepUserID { if deleteUserID == keepUserID {
deleteUserID = couple.Int("user2_id") deleteUserID = couple.Str("user2_id")
} }
for _, table := range []string{"period_records", "period_daily_records", "period_sex_records"} { for _, table := range []string{"period_records", "period_daily_records", "period_sex_records"} {
if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND couple_id IS NULL", deleteUserID); err != nil { if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND couple_id IS NULL", deleteUserID); err != nil {
@@ -104,7 +104,7 @@ func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
} }
} }
// daily 的旧 NULL 记录可能有重复日期,逐条迁入以避免情侣日期唯一索引冲突。 // daily 的旧 NULL 记录可能有重复日期,逐条迁入以避免情侣日期唯一索引冲突。
daily, err := tx.Query("SELECT id, record_date FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL ORDER BY id DESC FOR UPDATE", keepUserID) daily, err := tx.Query("SELECT id, record_date FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL ORDER BY created_at DESC, id DESC FOR UPDATE", keepUserID)
if err != nil { if err != nil {
return err return err
} }
@@ -114,10 +114,10 @@ func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
return err return err
} }
if existing != nil { if existing != nil {
if _, err := tx.Exec("DELETE FROM period_daily_records WHERE id = ?", record.Int("id")); err != nil { if _, err := tx.Exec("DELETE FROM period_daily_records WHERE id = ?", record.Str("id")); err != nil {
return err return err
} }
} else if _, err := tx.Exec("UPDATE period_daily_records SET couple_id = ? WHERE id = ?", cid, record.Int("id")); err != nil { } else if _, err := tx.Exec("UPDATE period_daily_records SET couple_id = ? WHERE id = ?", cid, record.Str("id")); err != nil {
return err return err
} }
} }
@@ -139,7 +139,7 @@ func periodSourceSelect(c *httpapi.Ctx) error {
if err := requirePeriodAccess(c); err != nil { if err := requirePeriodAccess(c); err != nil {
return err return err
} }
keepUserID := c.InInt("user_id") keepUserID := c.InTrim("user_id")
if err := selectPeriodSource(c, keepUserID); err != nil { if err := selectPeriodSource(c, keepUserID); err != nil {
return err return err
} }
+2 -2
View File
@@ -21,7 +21,7 @@ func permissionCtx(t *testing.T, method, uri, body string, steps []testdb.Step)
g.Request = httptest.NewRequest(method, uri, strings.NewReader(body)) g.Request = httptest.NewRequest(method, uri, strings.NewReader(body))
g.Request.Header.Set("Authorization", "Bearer test") g.Request.Header.Set("Authorization", "Bearer test")
g.Request.Header.Set("Content-Type", "application/json") g.Request.Header.Set("Content-Type", "application/json")
app := &httpapi.App{DB: &store.DB{DB: db}, Cfg: &config.Config{}} app := &httpapi.App{DB: store.WrapSQL(db), Cfg: &config.Config{}}
return &httpapi.Ctx{G: g, R: g.Request, W: out, DB: app.DB, App: app, Method: method}, out, s return &httpapi.Ctx{G: g, R: g.Request, W: out, DB: app.DB, App: app, Method: method}, out, s
} }
func uidStep(uid int64) testdb.Step { func uidStep(uid int64) testdb.Step {
@@ -36,7 +36,7 @@ func TestAgreeStartsCoolingWithoutUnbinding(t *testing.T) {
{Match: "FROM couples WHERE id=? FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}}, {Match: "FROM couples WHERE id=? FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}},
{Match: "FROM users WHERE id=? AND couple_id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}}, {Match: "FROM users WHERE id=? AND couple_id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}},
{Match: "status='pending' AND requester_id!=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}}, {Match: "status='pending' AND requester_id!=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
{Match: "cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR)", Args: []any{int64(8)}}, {Match: "cooling_until=(NOW() + INTERVAL '72 hours')", Args: []any{int64(8)}},
}) })
unbindRespond(c) unbindRespond(c)
if out.Code != 200 || s.Commits != 1 { if out.Code != 200 || s.Commits != 1 {
+21 -22
View File
@@ -77,7 +77,7 @@ func consecutiveDoneDays(rows []store.Row) int64 {
// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra) // checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra)
// happiness < 0 表示 null(不检查心情成就) // happiness < 0 表示 null(不检查心情成就)
func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int64, rename bool) { func checkPetAchievements(c *httpapi.Ctx, coupleID string, level, stage, happiness int64, rename bool) {
db := c.DB db := c.DB
rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID) rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID)
unlocked := map[string]bool{} unlocked := map[string]bool{}
@@ -126,28 +126,27 @@ func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int6
if len(toUnlock) == 0 { if len(toUnlock) == 0 {
return return
} }
// pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500, // 系统动态使用 NULL user_id,保留 GORM 公共模型与逻辑删除规则。
// Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。
db.ExecNoFK(func(q store.Querier) error { db.ExecNoFK(func(q store.Querier) error {
for _, key := range toUnlock { for _, key := range toUnlock {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?)", coupleID, key) q.Exec("INSERT INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?) ON CONFLICT DO NOTHING", coupleID, key)
a := petAchByKey(key) a := petAchByKey(key)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon) q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, NULL, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon)
} }
return nil return nil
}) })
} }
// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0) // coupleUserIDsOrdered 对应 SELECT user1_id AS id FROM couples WHERE id = ? UNION ALL SELECT user2_id AS id FROM couples WHERE id = ? → [user1, user2](缺省 0)
func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) { func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID string) (string, string) {
r1, r2 := coupleUserIDsRaw(c, coupleID) r1, r2 := coupleUserIDsRaw(c, coupleID)
return store.ToInt(r1), store.ToInt(r2) return store.ToStr(r1), store.ToStr(r2)
} }
// coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0) // coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0)
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) { func coupleUserIDsRaw(c *httpapi.Ctx, coupleID string) (any, any) {
rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID) rs, _ := c.DB.Query("SELECT user1_id AS id FROM couples WHERE id = ? UNION ALL SELECT user2_id AS id FROM couples WHERE id = ?", coupleID, coupleID)
var u1, u2 any = int64(0), int64(0) var u1, u2 any = "", ""
if len(rs) > 0 { if len(rs) > 0 {
u1 = rs[0]["id"] u1 = rs[0]["id"]
} }
@@ -172,8 +171,8 @@ func petInfo(c *httpapi.Ctx) error {
} }
if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil { if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil {
c.DB.ExecNoFK(func(q store.Querier) error { c.DB.ExecNoFK(func(q store.Querier) error {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt')", coupleID) q.Exec("INSERT INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt') ON CONFLICT DO NOTHING", coupleID)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣") q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, NULL, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣")
return nil return nil
}) })
} }
@@ -186,7 +185,7 @@ func petInfo(c *httpapi.Ctx) error {
equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID) equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID)
dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID) dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID)
u1raw, u2raw := coupleUserIDsRaw(c, coupleID) u1raw, u2raw := coupleUserIDsRaw(c, coupleID)
u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw) u1, u2 := store.ToStr(u1raw), store.ToStr(u2raw)
expForNext := pet.Int("level") * 100 expForNext := pet.Int("level") * 100
var expProgress any = 0 var expProgress any = 0
if expForNext > 0 { if expForNext > 0 {
@@ -248,7 +247,7 @@ func petAdopt(c *httpapi.Ctx) error {
actID := logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "") actID := logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "")
c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID) c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID)
checkPetAchievements(c, coupleID, 1, 1, 100, false) checkPetAchievements(c, coupleID, 1, 1, 100, false)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
// 使用活动ID记录发送事件,供发送日志和去重查询使用。 // 使用活动ID记录发送事件,供发送日志和去重查询使用。
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕", "thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕",
@@ -315,13 +314,13 @@ func petDailyAction(c *httpapi.Ctx) error {
if daily.Int(field) == 1 { if daily.Int(field) == 1 {
return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true}) return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true})
} }
if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Int("id")); err != nil { if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Str("id")); err != nil {
return err return err
} }
allDone := true allDone := true
for _, f := range petDailyFields { for _, f := range petDailyFields {
if daily.Int(f) == 0 && f != field { if daily.Int(f) == 0 && f != field {
row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Int("id")) row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Str("id"))
if row == nil || row.Int(f) == 0 { if row == nil || row.Int(f) == 0 {
allDone = false allDone = false
break break
@@ -358,20 +357,20 @@ func petDailyAction(c *httpapi.Ctx) error {
stageOut = newStage stageOut = newStage
} }
if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?", if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?",
newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Int("id")); err != nil { newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Str("id")); err != nil {
return err return err
} }
msg := petActionMessages[action] msg := petActionMessages[action]
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg) c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg)
if allDone { if allDone {
c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("id")) c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Str("id"))
c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Int("id")) c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Str("id"))
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid) c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid)
logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "") logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "")
} }
checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false) checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false)
actID := logActivity(c.DB, coupleID, uid, "pet_daily", msg, "") actID := logActivity(c.DB, coupleID, uid, "pet_daily", msg, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
notifyMsg := msg notifyMsg := msg
if allDone { if allDone {
notifyMsg = msg + ",今日所有任务完成!🎉" notifyMsg = msg + ",今日所有任务完成!🎉"
@@ -467,7 +466,7 @@ func petRename(c *httpapi.Ctx) error {
level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness") level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness")
} }
checkPetAchievements(c, coupleID, level, stage, happiness, true) checkPetAchievements(c, coupleID, level, stage, happiness, true)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕", "thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕",
}, "pages/pet/pet", actID, uid) }, "pages/pet/pet", actID, uid)
+23 -19
View File
@@ -13,7 +13,7 @@ import (
"past/internal/store" "past/internal/store"
) )
func guardSmokingCheckin(c *httpapi.Ctx, id int64) error { func guardSmokingCheckin(c *httpapi.Ctx, id string) error {
row, err := c.DB.QueryOne("SELECT id FROM smoking_accounts WHERE checkin_id = ?", id) row, err := c.DB.QueryOne("SELECT id FROM smoking_accounts WHERE checkin_id = ?", id)
if err != nil { if err != nil {
return err return err
@@ -54,7 +54,7 @@ func smokingDelta(kind string, quantity, stock int64) (int64, error) {
} }
} }
func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) { func smokingSnapshot(c *httpapi.Ctx, uid string) (M, error) {
result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "card_visible": int64(1), "available": true} result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "card_visible": int64(1), "available": true}
// 抽烟卡片显示/隐藏偏好:始终返回“当前登录用户”自己的设置(与查看对象无关),前端缓存失效后仍能恢复。 // 抽烟卡片显示/隐藏偏好:始终返回“当前登录用户”自己的设置(与查看对象无关),前端缓存失效后仍能恢复。
if me, err := c.DB.QueryOne("SELECT smoking_card_visible FROM users WHERE id = ?", c.UID()); err != nil { if me, err := c.DB.QueryOne("SELECT smoking_card_visible FROM users WHERE id = ?", c.UID()); err != nil {
@@ -81,13 +81,13 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
if account == nil { if account == nil {
return result, nil return result, nil
} }
aid := account.Int("id") aid := account.Str("id")
result["stock"] = account.Int("stock") result["stock"] = account.Int("stock")
summary, err := c.DB.QueryOne(`SELECT summary, err := c.DB.QueryOne(`SELECT
COALESCE(SUM(kind IN ('consume','receive_smoke')),0) AS total_count, COALESCE(COUNT(*) FILTER (WHERE kind IN ('consume','receive_smoke')),0) AS total_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= CURRENT_DATE),0) AS today_count, COALESCE(COUNT(*) FILTER (WHERE kind IN ('consume','receive_smoke') AND created_at >= CURRENT_DATE),0) AS today_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS week_count, COALESCE(COUNT(*) FILTER (WHERE kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS week_count,
COALESCE(SUM(kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS month_count, COALESCE(COUNT(*) FILTER (WHERE kind IN ('consume','receive_smoke') AND created_at >= ?),0) AS month_count,
COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS week_purchase_cents, COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS week_purchase_cents,
COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS month_purchase_cents, COALESCE(SUM(CASE WHEN kind = 'purchase' AND created_at >= ? THEN amount_cents ELSE 0 END),0) AS month_purchase_cents,
MAX(CASE WHEN kind IN ('consume','receive_smoke') THEN created_at ELSE NULL END) AS last_time MAX(CASE WHEN kind IN ('consume','receive_smoke') THEN created_at ELSE NULL END) AS last_time
@@ -106,13 +106,13 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
page, size, offset := pageParams(c, 20, 100) page, size, offset := pageParams(c, 20, 100)
result["logs_page"] = page result["logs_page"] = page
result["logs_total"] = countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", aid) result["logs_total"] = countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", aid)
logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", aid, size, offset) logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", aid, size, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result["logs"] = rows(logs) result["logs"] = rows(logs)
result["logs_has_more"] = offset+int64(len(logs)) < store.ToInt(result["logs_total"]) result["logs_has_more"] = offset+int64(len(logs)) < store.ToInt(result["logs_total"])
daily, err := c.DB.Query("SELECT DATE_FORMAT(created_at,'%Y-%m-%d') AS day, COUNT(*) AS count FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at,'%Y-%m-%d') ORDER BY day", aid) daily, err := c.DB.Query("SELECT TO_CHAR(created_at,'YYYY-MM-DD') AS day, COUNT(*) AS count FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= (CURRENT_DATE - INTERVAL '6 day') GROUP BY TO_CHAR(created_at,'YYYY-MM-DD') ORDER BY day", aid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -122,8 +122,8 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
func smokingInfo(c *httpapi.Ctx) error { func smokingInfo(c *httpapi.Ctx) error {
uid := c.UID() uid := c.UID()
requested := c.QInt("user_id") requested := c.Q("user_id")
if requested == 0 { if requested == "" || requested == "0" {
requested = uid requested = uid
} }
if requested != uid && requested != partnerUserId(c.DB, uid) { if requested != uid && requested != partnerUserId(c.DB, uid) {
@@ -168,9 +168,13 @@ func smokingAction(c *httpapi.Ctx) error {
return strconv.ParseInt(value, 10, 64) return strconv.ParseInt(value, 10, 64)
} }
amount, amountErr := optionalInt("amount_cents") amount, amountErr := optionalInt("amount_cents")
originalID, originalErr := optionalInt("original_id") originalID := c.InTrim("original_id")
if quantityErr != nil || amountErr != nil || originalErr != nil || (kind != "purchase" && amount != 0) || (kind != "undo" && originalID != 0) || (kind == "undo" && (originalID <= 0 || quantity != 0)) { originalErr := error(nil)
return c.Err(400, "数量、金额和记录标识必须是有效整数") if originalID != "" && !store.ValidUUID(originalID) {
originalErr = errors.New("记录 UUID 无效")
}
if quantityErr != nil || amountErr != nil || originalErr != nil || (kind != "purchase" && amount != 0) || (kind != "undo" && originalID != "") || (kind == "undo" && (originalID == "" || quantity != 0)) {
return c.Err(400, "数量和金额必须是有效整数,记录标识必须是有效 UUID")
} }
if key == "" || len(key) > 64 || utf8.RuneCountInString(note) > 200 || amount < 0 || amount > 100000000 { if key == "" || len(key) > 64 || utf8.RuneCountInString(note) > 200 || amount < 0 || amount > 100000000 {
return c.Err(400, "参数不正确") return c.Err(400, "参数不正确")
@@ -192,7 +196,7 @@ func smokingAction(c *httpapi.Ctx) error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO smoking_wallets (user_id) VALUES (?)", uid); err != nil { if _, err = tx.Exec("INSERT INTO smoking_wallets (user_id) VALUES (?) ON CONFLICT DO NOTHING", uid); err != nil {
return err return err
} }
account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid) account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid)
@@ -202,7 +206,7 @@ func smokingAction(c *httpapi.Ctx) error {
if account == nil { if account == nil {
return errors.New("库存账户创建失败") return errors.New("库存账户创建失败")
} }
aid, stock := account.Int("id"), account.Int("stock") aid, stock := account.Str("id"), account.Int("stock")
previous, err := tx.QueryOne("SELECT payload_hash FROM smoking_entries WHERE user_id = ? AND request_key = ?", aid, key) previous, err := tx.QueryOne("SELECT payload_hash FROM smoking_entries WHERE user_id = ? AND request_key = ?", aid, key)
if err != nil { if err != nil {
return err return err
@@ -271,7 +275,7 @@ func smokingAction(c *httpapi.Ctx) error {
smokeDesc = "TA 记录收到一支烟" smokeDesc = "TA 记录收到一支烟"
} }
if smokeDesc != "" { if smokeDesc != "" {
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
actID := logActivity(c.DB, c.CoupleID(), uid, "smoking_consume", smokeDesc, "") actID := logActivity(c.DB, c.CoupleID(), uid, "smoking_consume", smokeDesc, "")
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "戒烟打卡", "thing1": "戒烟打卡",
@@ -283,8 +287,8 @@ func smokingAction(c *httpapi.Ctx) error {
} }
return c.OK(M{"success": true, "info": info}) return c.OK(M{"success": true, "info": info})
} }
func nullableID(id int64) any { func nullableID(id string) any {
if id == 0 { if id == "" {
return nil return nil
} }
return id return id
@@ -37,12 +37,12 @@ func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) {
return nil return nil
}, Columns: []string{"today_count", "total_count", "week_count", "month_count", "week_purchase_cents", "month_purchase_cents", "last_time"}, Rows: [][]driver.Value{{int64(1), int64(120), int64(7), int64(29), int64(3050), int64(12500), "2026-10-11 09:00:00"}}}, }, Columns: []string{"today_count", "total_count", "week_count", "month_count", "week_purchase_cents", "month_purchase_cents", "last_time"}, Rows: [][]driver.Value{{int64(1), int64(120), int64(7), int64(29), int64(3050), int64(12500), "2026-10-11 09:00:00"}}},
testdb.Step{Match: "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", Args: []any{int64(2)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(120)}}}, testdb.Step{Match: "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", Args: []any{int64(2)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(120)}}},
testdb.Step{Match: "ORDER BY id DESC LIMIT ? OFFSET ?", Args: []any{int64(2), int64(20), int64(0)}, Columns: []string{"id"}}, testdb.Step{Match: "ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", Args: []any{int64(2), int64(20), int64(0)}, Columns: []string{"id"}},
testdb.Step{Match: "GROUP BY DATE_FORMAT", Columns: []string{"day", "count"}}, testdb.Step{Match: "GROUP BY TO_CHAR", Columns: []string{"day", "count"}},
) )
} }
c, _, _ := permissionCtx(t, "GET", "/api/smokingInfo?user_id=2", "", steps) c, _, _ := permissionCtx(t, "GET", "/api/smokingInfo?user_id=2", "", steps)
result, err := smokingSnapshot(c, 2) result, err := smokingSnapshot(c, "2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+2 -2
View File
@@ -15,7 +15,7 @@ func registerSongs(r *httpapi.Router) {
} }
func songsList(c *httpapi.Ctx) error { func songsList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
return demoListResponse(c, "songs", nil) return demoListResponse(c, "songs", nil)
} }
@@ -53,7 +53,7 @@ func songsAdd(c *httpapi.Ctx) error {
} }
name, artist := c.InStr("song_name"), c.InStr("artist") name, artist := c.InStr("song_name"), c.InStr("artist")
actID := logActivity(c.DB, cid, uid, "song_add", "收藏了「"+name+" - "+artist+"」", "") actID := logActivity(c.DB, cid, uid, "song_add", "收藏了「"+name+" - "+artist+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕", "thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕",
}, "pages/lovewords/lovewords", actID, uid) }, "pages/lovewords/lovewords", actID, uid)
+221 -443
View File
@@ -1,41 +1,29 @@
package handler package handler
import ( import (
"encoding/json" "github.com/google/uuid"
"encoding/xml" "go.uber.org/zap"
"fmt" "gorm.io/gorm"
"log/slog" "gorm.io/gorm/clause"
"os" "past/internal/model"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
"past/internal/config"
"past/internal/httpapi" "past/internal/httpapi"
"past/internal/store" "past/internal/store"
"past/internal/virtualpay"
"past/internal/wechat"
) )
const ( const (
settingFreeMB = "storage_free_mb" settingFreeMB = "storage_free_mb"
settingMonthPrice = "storage_plan_month_price" settingMonthPrice = "storage_plan_month_price"
settingMonthProduct = "storage_plan_month_product" settingYearPrice = "storage_plan_year_price"
settingYearPrice = "storage_plan_year_price"
settingYearProduct = "storage_plan_year_product"
settingPayMode = "storage_pay_mode"
settingPayOfferID = "storage_pay_offer_id"
settingPayAppKey = "storage_pay_app_key"
settingPaySandboxKey = "storage_pay_sandbox_key"
settingPayEnv = "storage_pay_env"
) )
const ( const (
defaultFreeMB int64 = 200 defaultFreeMB int64 = 200
defaultMonthPrice int64 = 500 defaultMonthPrice int64 = 500
defaultYearPrice int64 = 2990 defaultYearPrice int64 = 2990
defaultPayMode string = "short_series_goods"
) )
// ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。 // ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。
@@ -43,9 +31,11 @@ const ErrStorageQuotaExceeded = 10001
func registerStorage(r *httpapi.Router) { func registerStorage(r *httpapi.Router) {
r.Handle("storage_info", storageInfo) r.Handle("storage_info", storageInfo)
r.Handle("upload_preflight", uploadPreflight)
r.Handle("wechat_pay_notify", wechatPayNotify)
r.Handle("storage_order_status", storageOrderStatus)
r.Handle("storage_create_order", storageOrderCreate) r.Handle("storage_create_order", storageOrderCreate)
r.Handle("storage_order_create", storageOrderCreate) r.Handle("storage_order_create", storageOrderCreate)
r.Handle("storage_pay_notify", storagePayNotify)
r.Handle("admin_storage_settings", adminStorageSettings) r.Handle("admin_storage_settings", adminStorageSettings)
r.Handle("admin_storage_users", adminStorageUsers) r.Handle("admin_storage_users", adminStorageUsers)
r.Handle("admin_storage_user_update", adminStorageUserUpdate) r.Handle("admin_storage_user_update", adminStorageUserUpdate)
@@ -53,110 +43,60 @@ func registerStorage(r *httpapi.Router) {
} }
func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 { func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
if row == nil || row.IsNull("value") || row.Str("value") == "" { if row == nil || row.IsNull("value") || row.Str("value") == "" {
return def return def
} }
return row.Int("value") return row.Int("value")
} }
func storageSettingStr(c *httpapi.Ctx, key string, def string) string { // Count deduplicated MinIO objects, not directories on the API server.
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) func allStorageUsedBytes() int64 {
if row == nil || row.IsNull("value") { if up == nil || up.DB == nil {
return def return 0
} }
return row.Str("value") size, err := store.UsedFileBytes(up.DB.ORM, nil)
if err != nil {
zap.S().Errorw("读取存储用量失败", "err", err)
}
return size
}
func storageUsedBytes(c *httpapi.Ctx, uid, coupleId string) int64 {
owners, err := store.UploadOwners(c.DB.ORM, uid)
if err != nil {
return 0
}
size, err := store.UsedFileBytes(c.DB.ORM, owners)
if err != nil {
zap.S().Errorw("读取用户存储用量失败", "err", err)
}
return size
} }
// storagePaySetting 读取支付通道配置:settings 中有非空值则使用,否则回退到 config.yaml 默认值。 func storageVipUserIDs(c *httpapi.Ctx, uid string) []string {
// 与 storageSettingStr 不同,空字符串在此视为“未设置”,避免后台清空后误覆盖 config 默认。 ids := []string{uid}
func storagePaySetting(c *httpapi.Ctx, key, def string) string { if p := partnerUserId(c.DB, uid); p != "" {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
if row == nil || row.IsNull("value") || row.Str("value") == "" {
return def
}
return row.Str("value")
}
func vpayClient(c *httpapi.Ctx) *virtualpay.Client {
vp := c.App.Cfg.VirtualPay
offerID := storagePaySetting(c, settingPayOfferID, vp.OfferID)
appKey := storagePaySetting(c, settingPayAppKey, vp.AppKey)
sandboxKey := storagePaySetting(c, settingPaySandboxKey, vp.SandboxKey)
env := int(storageSettingInt(c, settingPayEnv, int64(vp.Env)))
return virtualpay.New(offerID, appKey, sandboxKey, env)
}
func dirSize(root string) int64 {
var total int64
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total
}
// storageUsedBytes 统计当前用户(或其情侣空间)已用存储字节数。
// 组成:情侣空间共享文件(couples/{space})+ 相关用户(本人,或情侣双方)的头像(avatars/{user_id})与个人上传文件(users/{id})。
func storageUsedBytes(c *httpapi.Ctx, uid, coupleID int64) int64 {
root := up.Dir
var total int64
if coupleID != 0 {
if space := coupleSpaceID(c, coupleID); space != "" {
total += dirSize(filepath.Join(root, "couples", space))
}
}
ids := []int64{uid}
if coupleID != 0 {
if cids := coupleUserIds(c, coupleID); len(cids) > 0 {
ids = cids
}
}
for _, id := range ids {
if u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", id); u != nil {
total += dirSize(filepath.Join(root, "avatars", u.Str("user_id")))
}
total += dirSize(filepath.Join(root, "users", store.ToStr(id)))
}
return total
}
func storageVipUserIDs(c *httpapi.Ctx, uid int64) []int64 {
ids := []int64{uid}
if p := partnerUserId(c.DB, uid); p != 0 {
ids = append(ids, p) ids = append(ids, p)
} }
return ids return ids
} }
func isStorageVip(c *httpapi.Ctx, uid int64) bool { func isStorageVip(c *httpapi.Ctx, uid string) bool {
for _, id := range storageVipUserIDs(c, uid) { a, err := store.StorageSpace(c.DB.ORM, uid)
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id) return err == nil && a.IsVip
if row != nil {
return true
}
}
return false
} }
func storageVipExpireAt(c *httpapi.Ctx, uid string) string {
func storageVipExpireAt(c *httpapi.Ctx, uid int64) string { a, err := store.StorageSpace(c.DB.ORM, uid)
var latest string if err == nil && a.ExpiresAt != nil {
for _, id := range storageVipUserIDs(c, uid) { return a.ExpiresAt.Format("2006-01-02 15:04:05")
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id)
if row != nil && row.Str("storage_vip_expire_at") > latest {
latest = row.Str("storage_vip_expire_at")
}
} }
return latest return ""
} }
// storageQuotaMB 返回用户实际可用的免费存储容量(MB): // storageQuotaMB 返回用户实际可用的免费存储容量(MB):
// 优先使用后台手动设置的自定义容量,否则回退到全局默认免费额度。 // 优先使用后台手动设置的自定义容量,否则回退到全局默认免费额度。
func storageQuotaMB(c *httpapi.Ctx, uid int64) int64 { func storageQuotaMB(c *httpapi.Ctx, uid string) int64 {
if uid != 0 { if uid != "" {
if row, _ := c.DB.QueryOne("SELECT storage_quota_mb FROM users WHERE id = ?", uid); row != nil && row.Int("storage_quota_mb") > 0 { if row, _ := c.DB.QueryOne("SELECT storage_quota_mb FROM users WHERE id = ?", uid); row != nil && row.Int("storage_quota_mb") > 0 {
return row.Int("storage_quota_mb") return row.Int("storage_quota_mb")
} }
@@ -164,44 +104,36 @@ func storageQuotaMB(c *httpapi.Ctx, uid int64) int64 {
return storageSettingInt(c, settingFreeMB, defaultFreeMB) return storageSettingInt(c, settingFreeMB, defaultFreeMB)
} }
// CheckStorageQuota 上传前检查存储配额;extraBytes 为本次待写入字节数。
func CheckStorageQuota(c *httpapi.Ctx, extraBytes int64) error {
uid := c.UID()
if uid == 0 {
return nil
}
if isStorageVip(c, uid) {
return nil
}
freeBytes := storageQuotaMB(c, uid) * 1024 * 1024
if storageUsedBytes(c, uid, c.CoupleID())+extraBytes > freeBytes {
return c.ErrCode(403, "存储空间不足,请升级为无限空间会员", ErrStorageQuotaExceeded)
}
return nil
}
func storageInfo(c *httpapi.Ctx) error { func storageInfo(c *httpapi.Ctx) error {
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
coupleID := c.CoupleID() coupleID := c.CoupleID()
freeMB := storageQuotaMB(c, uid) freeMB := storageQuotaMB(c, uid)
vipExpire := storageVipExpireAt(c, uid) plans, err := storageCatalog(c.DB.ORM, true)
plans := []M{ if err != nil {
{"key": "month", "name": "包月无限空间", "price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), "product_id": storageSettingStr(c, settingMonthProduct, "")}, return err
{"key": "year", "name": "包年无限空间", "price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), "product_id": storageSettingStr(c, settingYearProduct, "")}, }
allowance, err := store.StorageSpace(c.DB.ORM, uid)
if err != nil {
return err
} }
resp := M{ resp := M{
"used_bytes": storageUsedBytes(c, uid, coupleID), "pay_provider": "wechat",
"free_mb": freeMB, "used_bytes": storageUsedBytes(c, uid, coupleID),
"free_bytes": freeMB * 1024 * 1024, "free_mb": freeMB,
"is_vip": isStorageVip(c, uid), "free_bytes": freeMB * 1024 * 1024,
"is_vip": allowance.IsVip,
"is_unlimited": allowance.Unlimited,
"quota_mb": allowance.QuotaMb,
"plan_name": allowance.PlanName,
"quota_bytes": allowance.QuotaMb * 1024 * 1024,
"vip_expire_at": nil, "vip_expire_at": nil,
"plans": plans, "plans": plans,
} }
if vipExpire != "" { if allowance.ExpiresAt != nil {
resp["vip_expire_at"] = vipExpire resp["vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05")
} }
return c.OK(resp) return c.OK(resp)
} }
@@ -210,265 +142,79 @@ func storageOrderCreate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
uid := c.UID() if c.UID() == "" {
if uid == 0 {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
planKey := c.InTrim("plan_key") if !c.App.Cfg.WechatPay.Enabled {
var price int64 return c.Err(503, "微信支付服务未启用")
var productID string
switch planKey {
case "month":
price = storageSettingInt(c, settingMonthPrice, defaultMonthPrice)
productID = storageSettingStr(c, settingMonthProduct, "")
case "year":
price = storageSettingInt(c, settingYearPrice, defaultYearPrice)
productID = storageSettingStr(c, settingYearProduct, "")
default:
return c.Err(400, "无效的套餐")
} }
if productID == "" { var plan model.StoragePlan
return c.Err(400, "该套餐暂未开放,请稍后再试") r := c.DB.ORM.Where("key=? AND enabled=?", c.InTrim("plan_key"), true).Find(&plan)
if r.Error != nil {
return r.Error
} }
user, _ := c.DB.QueryOne("SELECT session_key FROM users WHERE id = ?", uid) if r.RowsAffected == 0 {
if user == nil || user.Str("session_key") == "" { return c.Err(400, "套餐已下架或不存在,请刷新")
return c.Err(400, "支付凭证已过期,请重新登录后重试")
} }
vp := vpayClient(c) if c.InHas("expected_price_cents") && c.InInt("expected_price_cents") != plan.PriceCents {
if vp.OfferID == "" || vp.AppKey == "" { return c.Err(409, "套餐价格已变更,请刷新后重新购买")
return c.Err(500, "支付服务未配置,请联系管理员")
} }
// outTradeNo 要求 8-32 位、不能以下划线开头;NewToken 为 32 位 hex,取前 31 位 + 前缀 S 共 32 位。 if value := c.InTrim("expected_plan_version"); value != "" {
outTradeNo := "S" + httpapi.NewToken()[:31] v, err := time.Parse(time.RFC3339Nano, value)
attach, _ := json.Marshal(M{"uid": uid, "plan": planKey}) if err != nil || !v.Equal(plan.UpdatedAt) {
signData, err := vp.BuildSignData(productID, int(price), outTradeNo, string(attach)) return c.Err(409, "套餐配置已变更,请刷新后重新购买")
if err != nil {
return c.Err(500, "生成支付参数失败")
}
var cid any
if cidv := c.CoupleID(); cidv != 0 {
cid = cidv
}
if _, err := c.DB.Insert("INSERT INTO storage_orders (user_id, couple_id, out_trade_no, plan_key, product_id, amount_cents, env, status, attach) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)",
uid, cid, outTradeNo, planKey, productID, price, vp.Env, string(attach)); err != nil {
return err
}
return c.OK(M{
"out_trade_no": outTradeNo,
"mode": storageSettingStr(c, settingPayMode, defaultPayMode),
"sign_data": signData,
"pay_sig": vp.PaySig(signData),
"signature": virtualpay.SessionSignature(user.Str("session_key"), signData),
// wx.requestVirtualPayment 所需的独立参数(与 signData 一致,供前端直接组装)
"offer_id": vp.OfferID,
"buy_quantity": 1,
"env": vp.Env,
"currency_type": "CNY",
"product_id": productID,
"goods_price": price,
})
}
// xpayNotify 道具发货推送(xpay_goods_deliver_notify)的 XML 消息结构。
type xpayNotify struct {
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime string `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Event string `xml:"Event"`
OpenId string `xml:"OpenId"`
OutTradeNo string `xml:"OutTradeNo"`
Env string `xml:"Env"`
WeChatPayInfo struct {
MchOrderNo string `xml:"MchOrderNo"`
} `xml:"WeChatPayInfo"`
GoodsInfo struct {
ProductId string `xml:"ProductId"`
Quantity string `xml:"Quantity"`
} `xml:"GoodsInfo"`
}
func storagePayNotify(c *httpapi.Ctx) error {
token := c.App.Cfg.VirtualPay.NotifyToken
// 消息推送 URL 有效性验证(微信在后台配置时发起 GET 请求)
if c.Method == "GET" {
if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) {
return c.Err(403, "签名验证失败")
}
return c.Raw(200, "text/plain; charset=utf-8", []byte(c.Q("echostr")))
}
// 发货推送验签(明文安全模式)
if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) {
return c.Err(403, "签名验证失败")
}
n, err := parseXpayNotify(c.Body())
if err != nil || n.Event != "xpay_goods_deliver_notify" {
// 无法解析或非道具发货事件(退款/投诉/风控等):忽略内容,但仍应答成功,避免微信无限重试
return notifyReply(c, 0, "success")
}
if n.OutTradeNo == "" {
return notifyReply(c, 0, "success")
}
if err := deliverStorageOrder(c.DB, n.OutTradeNo, n.WeChatPayInfo.MchOrderNo); err != nil {
// 发货失败返回非 0,微信将按策略重试(最多 15 次)
return notifyReply(c, 1, "deliver failed")
}
return notifyReply(c, 0, "success")
}
// parseXpayNotify 解析微信发货推送的 XML 消息体(明文模式)。
func parseXpayNotify(body []byte) (*xpayNotify, error) {
var n xpayNotify
if err := xml.Unmarshal(body, &n); err != nil {
return nil, err
}
return &n, nil
}
// notifyReply 输出微信消息推送要求的应答:推送为 XML 时,响应也必须为 XML。
func notifyReply(c *httpapi.Ctx, errCode int, errMsg string) error {
body := "<xml><ErrCode>" + store.ToStr(errCode) + "</ErrCode><ErrMsg><![CDATA[" + errMsg + "]]></ErrMsg></xml>"
return c.Raw(200, "application/xml; charset=utf-8", []byte(body))
}
// deliverStorageOrder 按订单号发货:标记支付成功并为用户开通/续期会员。
// 注意:该函数不直接向客户端写响应,而是返回 error 供调用方统一应答,避免污染微信回调的 XML 响应。
func deliverStorageOrder(db *store.DB, outTradeNo, wxOrderID string) error {
order, _ := db.QueryOne("SELECT id, user_id, plan_key, status FROM storage_orders WHERE out_trade_no = ?", outTradeNo)
if order == nil {
return fmt.Errorf("订单不存在: %s", outTradeNo)
}
if order.Str("status") == "success" {
return nil
}
if _, err := db.Exec("UPDATE storage_orders SET status = 'success', wx_order_id = ?, paid_at = NOW() WHERE id = ?", wxOrderID, order.Int("id")); err != nil {
return err
}
months := 1
if order.Str("plan_key") == "year" {
months = 12
}
_, err := db.Exec("UPDATE users SET storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", months, order.Int("user_id"))
return err
}
// storageSettingIntDB / storagePaySettingDB:定时任务无请求上下文,直接以 DB 读取设置。
func storageSettingIntDB(db *store.DB, key string, def int64) int64 {
row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
if row == nil || row.IsNull("value") || row.Str("value") == "" {
return def
}
return row.Int("value")
}
func storagePaySettingDB(db *store.DB, key, def string) string {
row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
if row == nil || row.IsNull("value") || row.Str("value") == "" {
return def
}
return row.Str("value")
}
// RunStorageOrderQuery 兜底查单:定时扫描 pending 订单,通过 query_order 主动补发货。
// 用于微信发货推送丢失/延迟时的兜底,保证已支付订单最终开通会员。
func RunStorageOrderQuery(db *store.DB, wx *wechat.Client, cfg *config.Config, interval time.Duration) {
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("storage_order_query panic", "err", r)
}
}()
storageOrderQueryOnce(db, wx, cfg)
}()
time.Sleep(interval)
}
}
func storageOrderQueryOnce(db *store.DB, wx *wechat.Client, cfg *config.Config) {
vpCfg := cfg.VirtualPay
offerID := storagePaySettingDB(db, settingPayOfferID, vpCfg.OfferID)
appKey := storagePaySettingDB(db, settingPayAppKey, vpCfg.AppKey)
sandboxKey := storagePaySettingDB(db, settingPaySandboxKey, vpCfg.SandboxKey)
env := int(storageSettingIntDB(db, settingPayEnv, int64(vpCfg.Env)))
vp := virtualpay.New(offerID, appKey, sandboxKey, env)
if vp.OfferID == "" || vp.AppKey == "" {
return
}
// 只补最近 24 小时内的 pending 订单;env 与当前配置一致才可查单。
orders, err := db.Query("SELECT user_id, out_trade_no FROM storage_orders WHERE status = 'pending' AND env = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY id DESC LIMIT 50", env)
if err != nil || len(orders) == 0 {
return
}
tok := wx.AccessToken()
if tok == "" {
return
}
for _, o := range orders {
openid := userOpenId(db, o.Int("user_id"))
if openid == "" {
continue
}
outTradeNo := o.Str("out_trade_no")
res, err := vp.QueryOrder(tok, openid, outTradeNo)
if err != nil || res.ErrCode != 0 {
continue
}
// status:2=已支付待发货 3=发货中 4=已发货,均视为支付成功
if res.Order.Status >= 2 && res.Order.Status <= 4 {
wxOrderID := res.Order.WxOrderID
if wxOrderID == "" {
wxOrderID = res.Order.OrderID
}
if err := deliverStorageOrder(db, outTradeNo, wxOrderID); err != nil {
slog.Error("storage_order_query 补发货失败", "out_trade_no", outTradeNo, "err", err)
}
} else if res.Order.Status == 6 {
// 订单已关闭,标记关闭不再重试
_, _ = db.Exec("UPDATE storage_orders SET status = 'closed' WHERE out_trade_no = ? AND status = 'pending'", outTradeNo)
} }
} }
return storageWechatOrderCreate(c, plan)
} }
func adminStorageSettings(c *httpapi.Ctx) error { func adminStorageSettings(c *httpapi.Ctx) error {
if c.Method == "GET" { if c.Method == "GET" {
return c.OK(M{ var plans []model.StoragePlan
"free_mb": storageSettingInt(c, settingFreeMB, defaultFreeMB), var revision, free int64
"month_price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
"month_product_id": storageSettingStr(c, settingMonthProduct, ""), if e := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; e != nil {
"year_price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), return e
"year_product_id": storageSettingStr(c, settingYearProduct, ""), }
"pay_mode": storageSettingStr(c, settingPayMode, defaultPayMode), var e error
"pay_env": int(storageSettingInt(c, settingPayEnv, int64(c.App.Cfg.VirtualPay.Env))), plans, e = storageCatalog(tx, false)
"pay_offer_id": storagePaySetting(c, settingPayOfferID, c.App.Cfg.VirtualPay.OfferID), if e != nil {
"pay_app_key": storagePaySetting(c, settingPayAppKey, c.App.Cfg.VirtualPay.AppKey), return e
"pay_sandbox_key": storagePaySetting(c, settingPaySandboxKey, c.App.Cfg.VirtualPay.SandboxKey), }
revision, e = catalogVersion(tx)
if e != nil {
return e
}
free = defaultFreeMB
var setting model.Setting
r := tx.Where("key=?", settingFreeMB).Find(&setting)
if r.Error != nil {
return r.Error
}
if r.RowsAffected > 0 {
free = store.ToInt(setting.Value)
}
return nil
}) })
if err != nil {
return err
}
month, year := int64(0), int64(0)
for _, p := range plans {
if p.Key == "month" {
month = p.PriceCents
}
if p.Key == "year" {
year = p.PriceCents
}
}
return c.OK(M{"pay_provider": "wechat", "wechat_pay_ready": c.App.WechatPay != nil, "wechat_pay_mch_id": c.App.Cfg.WechatPay.MchID, "free_mb": free, "plans": plans, "revision": revision, "month_price_cents": month, "year_price_cents": year})
} }
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
pairs := []struct { return saveStorageCatalog(c)
k string
v any
}{
{settingFreeMB, c.InInt("free_mb")},
{settingMonthPrice, c.InInt("month_price_cents")},
{settingMonthProduct, strings.TrimSpace(c.InStr("month_product_id"))},
{settingYearPrice, c.InInt("year_price_cents")},
{settingYearProduct, strings.TrimSpace(c.InStr("year_product_id"))},
{settingPayMode, strings.TrimSpace(c.InStr("pay_mode"))},
{settingPayOfferID, strings.TrimSpace(c.InStr("pay_offer_id"))},
{settingPayAppKey, strings.TrimSpace(c.InStr("pay_app_key"))},
{settingPaySandboxKey, strings.TrimSpace(c.InStr("pay_sandbox_key"))},
{settingPayEnv, c.InInt("pay_env")},
}
for _, p := range pairs {
if err := setSetting(c, p.k, p.v); err != nil {
return err
}
}
return c.OK(M{"success": true})
} }
// storagePlanLabel 套餐中文名(后台展示用) // storagePlanLabel 套餐中文名(后台展示用)
@@ -494,7 +240,7 @@ func adminStorageUsers(c *httpapi.Ctx) error {
if !c.QHas("sort") { if !c.QHas("sort") {
sortKey = "used_desc" sortKey = "used_desc"
} }
sql := "SELECT id, user_id, username, nickname, avatar, gender, couple_id, created_at, last_login_at, storage_quota_mb, storage_plan, storage_plan_started_at, storage_vip_expire_at FROM users" sql := "SELECT id, user_id, username, nickname, avatar, gender, couple_id, created_at, last_login_at, storage_quota_mb, storage_plan, storage_plan_name, storage_plan_started_at, storage_vip_expire_at FROM users"
var where []string var where []string
var params []any var params []any
if vip == "vip" { if vip == "vip" {
@@ -504,9 +250,12 @@ func adminStorageUsers(c *httpapi.Ctx) error {
} }
if search != "" { if search != "" {
like := "%" + search + "%" like := "%" + search + "%"
if isNumeric(search) { if store.ValidUUID(search) {
where = append(where, "(id = ? OR user_id = ? OR nickname LIKE ? OR username LIKE ?)") where = append(where, "(id = ? OR nickname LIKE ? OR username LIKE ?)")
params = append(params, store.ToInt(search), store.ToInt(search), like, like) params = append(params, search, like, like)
} else if isNumeric(search) {
where = append(where, "(user_id = ? OR nickname LIKE ? OR username LIKE ?)")
params = append(params, search, like, like)
} else { } else {
where = append(where, "(nickname LIKE ? OR username LIKE ?)") where = append(where, "(nickname LIKE ? OR username LIKE ?)")
params = append(params, like, like) params = append(params, like, like)
@@ -522,7 +271,7 @@ func adminStorageUsers(c *httpapi.Ctx) error {
today := now() today := now()
out := make([]store.Row, 0, len(rs)) out := make([]store.Row, 0, len(rs))
for _, u := range rs { for _, u := range rs {
uid, cid := u.Int("id"), u.Int("couple_id") uid, cid := u.Str("id"), u.Str("couple_id")
expire := u.Str("storage_vip_expire_at") expire := u.Str("storage_vip_expire_at")
quota := u.Int("storage_quota_mb") quota := u.Int("storage_quota_mb")
u["used_bytes"] = storageUsedBytes(c, uid, cid) u["used_bytes"] = storageUsedBytes(c, uid, cid)
@@ -531,7 +280,22 @@ func adminStorageUsers(c *httpapi.Ctx) error {
quota = storageSettingInt(c, settingFreeMB, defaultFreeMB) quota = storageSettingInt(c, settingFreeMB, defaultFreeMB)
} }
u["free_mb"] = quota u["free_mb"] = quota
u["plan_label"] = storagePlanLabel(u.Str("storage_plan")) u["plan_label"] = u.Str("storage_plan_name")
if u.Str("plan_label") == "" {
u["plan_label"] = storagePlanLabel(u.Str("storage_plan"))
}
allowance, e := store.StorageSpace(c.DB.ORM, uid)
if e != nil {
return e
}
u["quota_mb"], u["is_unlimited"] = allowance.QuotaMb, allowance.Unlimited
u["storage_vip"] = allowance.IsVip
if allowance.PlanName != "" {
u["plan_label"] = allowance.PlanName
}
if allowance.ExpiresAt != nil {
u["storage_vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05")
}
out = append(out, u) out = append(out, u)
} }
switch sortKey { switch sortKey {
@@ -560,73 +324,81 @@ func adminStorageUserUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
userID := c.InInt("user_id") uid := c.InTrim("user_id")
if userID <= 0 { if !store.ValidUUID(uid) {
return c.Err(400, "用户ID无效") return c.Err(400, "用户ID无效")
} }
user, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ?", userID) quota := c.InInt("quota_mb")
if user == nil { if quota < 0 || quota > 104857600 {
return c.Err(404, "用户不存在") return c.Err(400, "容量无效")
} }
// 1) 自定义容量(0 = 恢复默认免费额度) key := c.InTrim("plan_key")
if c.InHas("quota_mb") { var plan model.StoragePlan
quota := c.InInt("quota_mb") if c.InHas("plan_key") && key != "free" {
if quota < 0 { r := c.DB.ORM.Where("key=?", key).Find(&plan)
quota = 0 if r.Error != nil {
return r.Error
} }
if _, err := c.DB.Exec("UPDATE users SET storage_quota_mb = ? WHERE id = ?", quota, userID); err != nil { if r.RowsAffected == 0 {
return c.Err(400, "套餐不存在")
}
}
var expiry *time.Time
if value := c.InTrim("vip_expire_at"); value != "" {
parsed, e := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
if e != nil {
return c.Err(400, "到期时间格式无效")
}
expiry = &parsed
}
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id=?", uid).Error; err != nil {
return err return err
} }
} if c.InHas("quota_mb") {
// 2) 套餐 / 到期时间 if err := tx.Model(&user).Update("storage_quota_mb", quota).Error; err != nil {
if c.InHas("plan_key") { return err
planKey := c.InTrim("plan_key")
switch planKey {
case "month", "year":
months := 1
if planKey == "year" {
months = 12
} }
expire := c.InTrim("vip_expire_at") }
if expire == "" { if c.InHas("plan_key") {
if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", planKey, months, userID); err != nil { if err := tx.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
return err
}
if key == "free" {
return tx.Model(&user).Updates(map[string]any{"storage_plan": "", "storage_plan_name": "", "storage_plan_started_at": nil, "storage_vip_expire_at": nil}).Error
}
start := time.Now()
end := storageExpiry(start, plan.Months)
if expiry != nil {
end = *expiry
}
ent := model.StorageEntitlement{UserId: uid, SourceKey: "admin:" + uuid.NewString(), PlanKey: key, PlanName: plan.Name, QuotaMb: plan.QuotaMb, StartsAt: start, ExpiresAt: end}
if err := tx.Create(&ent).Error; err != nil {
return err
}
return tx.Model(&user).Updates(map[string]any{"storage_plan": key, "storage_plan_name": plan.Name, "storage_plan_started_at": start, "storage_vip_expire_at": end}).Error
}
if c.InHas("vip_expire_at") {
if expiry == nil {
if err := tx.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
return err return err
} }
} else { } else {
if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = ? WHERE id = ?", planKey, expire, userID); err != nil { if err := tx.Model(&model.StorageEntitlement{}).Where("user_id=? AND starts_at<=NOW() AND expires_at>NOW()", uid).Update("expires_at", *expiry).Error; err != nil {
return err return err
} }
} }
case "free": return tx.Model(&user).Update("storage_vip_expire_at", expiry).Error
if _, err := c.DB.Exec("UPDATE users SET storage_plan = '', storage_plan_started_at = NULL, storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil {
return err
}
default:
return c.Err(400, "无效的套餐")
}
} else if c.InHas("vip_expire_at") {
expire := c.InTrim("vip_expire_at")
if expire == "" {
if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil {
return err
}
} else {
if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = ? WHERE id = ?", expire, userID); err != nil {
return err
}
} }
return nil
})
if err != nil {
return err
} }
return c.OK(M{"success": true}) return c.OK(M{"success": true})
} }
// envLabel 支付环境中文名。
func envLabel(env int64) string {
if env == 0 {
return "正式"
}
return "沙箱"
}
// adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。 // adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。
func adminStorageOrders(c *httpapi.Ctx) error { func adminStorageOrders(c *httpapi.Ctx) error {
search := strings.TrimSpace(c.Q("search")) search := strings.TrimSpace(c.Q("search"))
@@ -641,29 +413,35 @@ func adminStorageOrders(c *httpapi.Ctx) error {
params = append(params, status) params = append(params, status)
} }
if search != "" { if search != "" {
if isNumeric(search) { like := "%" + search + "%"
where = append(where, "(o.out_trade_no = ? OR o.user_id = ? OR u.user_id = ?)") if store.ValidUUID(search) {
params = append(params, search, store.ToInt(search), store.ToInt(search)) where = append(where, "(o.out_trade_no = ? OR o.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, search, search, like, like)
} else if isNumeric(search) {
where = append(where, "(o.out_trade_no = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, search, store.ToInt(search), like, like)
} else { } else {
like := "%" + search + "%" where = append(where, "(o.out_trade_no = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)") params = append(params, search, like, like)
params = append(params, like, like)
} }
} }
sql := `SELECT o.id, o.user_id AS uid, u.user_id AS display_id, u.nickname, u.username, sql := `SELECT o.id, o.user_id AS uid, u.user_id AS display_id, u.nickname, u.username,
o.out_trade_no, o.plan_key, o.product_id, o.amount_cents, o.env, o.status, o.wx_order_id, o.created_at, o.paid_at o.out_trade_no, o.plan_key, o.plan_name, o.plan_quota_mb, o.plan_months, o.amount_cents, o.pay_provider, o.status, o.wx_order_id, o.created_at, o.paid_at
FROM storage_orders o LEFT JOIN users u ON u.id = o.user_id` FROM storage_orders o LEFT JOIN users u ON u.id = o.user_id`
if len(where) > 0 { if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ") sql += " WHERE " + strings.Join(where, " AND ")
} }
sql += " ORDER BY o.id DESC" sql += " ORDER BY o.created_at DESC, o.id DESC"
rs, err := c.DB.Query(sql, params...) rs, err := c.DB.Query(sql, params...)
if err != nil { if err != nil {
return err return err
} }
for _, o := range rs { for _, o := range rs {
o["plan_label"] = storagePlanLabel(o.Str("plan_key")) o["plan_label"] = o.Str("plan_name")
o["env_label"] = envLabel(o.Int("env")) if o.Str("plan_label") == "" {
o["plan_label"] = storagePlanLabel(o.Str("plan_key"))
}
} }
return c.OK(paged(c, rs)) return c.OK(paged(c, rs))
} }
@@ -0,0 +1,10 @@
package handler
import "time"
// Match PostgreSQL interval semantics: Jan 31 + one month ends in February.
func storageExpiry(start time.Time, months int64) time.Time {
first := time.Date(start.Year(), start.Month()+time.Month(months), 1, start.Hour(), start.Minute(), start.Second(), start.Nanosecond(), start.Location())
last := time.Date(first.Year(), first.Month()+1, 0, 0, 0, 0, 0, start.Location()).Day()
return time.Date(first.Year(), first.Month(), min(start.Day(), last), start.Hour(), start.Minute(), start.Second(), start.Nanosecond(), start.Location())
}
@@ -0,0 +1,19 @@
package handler
import (
"testing"
"time"
)
func TestStorageExpiryClampsMonthEnd(t *testing.T) {
for _, tc := range []struct {
start string
months int64
want string
}{{"2026-01-31", 1, "2026-02-28"}, {"2024-01-31", 1, "2024-02-29"}, {"2024-02-29", 12, "2025-02-28"}} {
start, _ := time.Parse(time.DateOnly, tc.start)
if got := storageExpiry(start, tc.months).Format(time.DateOnly); got != tc.want {
t.Fatalf("%s + %d months = %s, want %s", tc.start, tc.months, got, tc.want)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
)
func storageCatalog(db *gorm.DB, enabledOnly bool) ([]model.StoragePlan, error) {
plans := []model.StoragePlan{}
if enabledOnly {
db = db.Where("enabled=?", true)
}
err := db.Order("sort_order ASC, created_at ASC, id ASC").Find(&plans).Error
return plans, err
}
func catalogVersion(db *gorm.DB) (int64, error) {
var s model.Setting
r := db.Where("key=?", "storage_plans_revision").Find(&s)
return store.ToInt(s.Value), r.Error
}
func writeStorageSetting(tx *gorm.DB, key string, value int64) error {
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}}, DoUpdates: clause.Assignments(map[string]any{"value": fmt.Sprint(value)})}).Create(&model.Setting{Key: key, Value: fmt.Sprint(value)}).Error
}
func validateStoragePlans(plans []model.StoragePlan) error {
if len(plans) > 100 {
return fmt.Errorf("套餐最多100档")
}
seen := map[string]bool{}
for i := range plans {
p := &plans[i]
p.Name = strings.TrimSpace(p.Name)
if p.Key == "" {
p.Key = uuid.NewString()
}
if p.Key != "month" && p.Key != "year" && !store.ValidUUID(p.Key) {
return fmt.Errorf("无效的套餐标识")
}
if seen[p.Key] {
return fmt.Errorf("套餐标识重复")
}
seen[p.Key] = true
if p.Name == "" || utf8.RuneCountInString(p.Name) > 60 || p.Months < 1 || p.Months > 120 || p.QuotaMb < 0 || p.QuotaMb > 104857600 || p.PriceCents < 1 || p.PriceCents > 100000000 || p.SortOrder < 0 || p.SortOrder > 1000000 {
return fmt.Errorf("套餐名称、容量、有效月数、价格或排序无效")
}
}
return nil
}
var errCatalogConflict = errors.New("套餐配置已被其他管理员修改,请刷新后重试")
func saveStorageCatalog(c *httpapi.Ctx) error {
free := c.InInt("free_mb")
if free < 0 || free > 104857600 {
return c.Err(400, "免费容量无效")
}
var plans []model.StoragePlan
modern := c.InHas("plans")
if modern {
raw, err := json.Marshal(c.Input()["plans"])
if err != nil {
return c.Err(400, "套餐列表格式无效")
}
// Decode only editable fields, never client-supplied BaseModel IDs/timestamps.
var input []struct {
Key string `json:"key"`
Name string `json:"name"`
QuotaMb int64 `json:"quota_mb"`
Months int64 `json:"months"`
PriceCents int64 `json:"price_cents"`
Enabled bool `json:"enabled"`
SortOrder int64 `json:"sort_order"`
}
if string(raw) == "null" || json.Unmarshal(raw, &input) != nil {
return c.Err(400, "套餐列表格式无效")
}
for _, p := range input {
plans = append(plans, model.StoragePlan{Key: p.Key, Name: p.Name, QuotaMb: p.QuotaMb, Months: p.Months, PriceCents: p.PriceCents, Enabled: p.Enabled, SortOrder: p.SortOrder})
}
if err := validateStoragePlans(plans); err != nil {
return c.Err(400, err.Error())
}
if !c.InHas("revision") {
return c.Err(400, "缺少套餐配置版本,请刷新")
}
}
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; err != nil {
return err
}
version, err := catalogVersion(tx)
if err != nil {
return err
}
if modern && version != c.InInt("revision") {
return errCatalogConflict
}
if modern {
keys := []string{}
for _, p := range plans {
var existing model.StoragePlan
r := tx.Unscoped().Where("key=?", p.Key).Find(&existing)
if r.Error != nil {
return r.Error
}
if r.RowsAffected == 0 {
if err := tx.Create(&p).Error; err != nil {
return err
}
} else {
if existing.DeletedAt.Valid {
return fmt.Errorf("已删除套餐不可重新使用原标识")
}
if err := tx.Model(&existing).Updates(map[string]any{"name": p.Name, "quota_mb": p.QuotaMb, "months": p.Months, "price_cents": p.PriceCents, "enabled": p.Enabled, "sort_order": p.SortOrder}).Error; err != nil {
return err
}
}
keys = append(keys, p.Key)
}
q := tx.Where("1=1")
if len(keys) > 0 {
q = q.Where("key NOT IN ?", keys)
}
if err := q.Delete(&model.StoragePlan{}).Error; err != nil {
return err
}
} else {
// Older admin clients can still update the two original plan prices.
for key, field := range map[string]string{"month": "month_price_cents", "year": "year_price_cents"} {
if !c.InHas(field) {
continue
}
price := c.InInt(field)
if price <= 0 || price > 100000000 {
return fmt.Errorf("套餐价格无效")
}
if err := tx.Model(&model.StoragePlan{}).Where("key=?", key).Update("price_cents", price).Error; err != nil {
return err
}
}
}
if err := writeStorageSetting(tx, settingFreeMB, free); err != nil {
return err
}
return writeStorageSetting(tx, "storage_plans_revision", version+1)
})
if errors.Is(err, errCatalogConflict) {
return c.Err(409, err.Error())
}
if err != nil {
return c.Err(400, err.Error())
}
return c.OK(M{"success": true})
}
@@ -0,0 +1,137 @@
package handler
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"past/internal/config"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
)
func exerciseStorageTiers(t *testing.T, db *store.DB, app *httpapi.App, request func(string, string, string, M, int) M, uid, token, partnerToken string) {
t.Helper()
original := request("admin_storage_settings", "GET", "admin", nil, 200)
revision := store.ToInt(original["revision"])
small, large, hidden := uuid.NewString(), uuid.NewString(), uuid.NewString()
plans := []M{
{"key": small, "name": "1GB 测试", "quota_mb": 1024, "months": 1, "price_cents": 1, "enabled": true, "sort_order": 2},
{"key": large, "name": "5GB 季度", "quota_mb": 5120, "months": 3, "price_cents": 1800, "enabled": true, "sort_order": 1},
{"key": hidden, "name": "下架套餐", "quota_mb": 10240, "months": 12, "price_cents": 10000, "enabled": false, "sort_order": 0},
}
request("admin_storage_settings", "POST", token, M{"free_mb": 200, "plans": plans, "revision": revision}, 401)
invalid := append([]M{}, plans...)
invalid[0] = M{"key": small, "name": "bad", "quota_mb": -1, "months": 1, "price_cents": 1, "enabled": true}
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": invalid, "revision": revision}, 400)
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": plans, "revision": revision}, 200)
request("admin_storage_settings", "POST", "admin", M{"free_mb": 100, "plans": plans, "revision": revision}, 409)
info := request("storage_info", "GET", token, nil, 200)
visible := info["plans"].([]any)
if len(visible) != 2 || visible[0].(M)["key"] != large {
t.Fatalf("hidden/sort rules wrong: %+v", visible)
}
oldCfg, oldGateway := app.Cfg.WechatPay, app.WechatPay
defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = oldGateway }()
app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "tier-app", MchID: "tier-merchant"}
fake := &paymentFake{}
app.WechatPay = fake
request("storage_create_order", "POST", token, M{"plan_key": large, "expected_price_cents": 1}, 409)
request("storage_create_order", "POST", token, M{"plan_key": hidden}, 400)
request("storage_create_order", "POST", token, M{"plan_key": "does-not-exist"}, 400)
// Remove prior test memberships so finite quotas can be checked independently.
owners, err := store.UploadOwners(db.ORM, uid)
if err != nil {
t.Fatal(err)
}
if err := db.ORM.Where("user_id IN ?", owners).Delete(&model.StorageEntitlement{}).Error; err != nil {
t.Fatal(err)
}
if err := db.ORM.Model(&model.User{}).Where("id IN ?", owners).Updates(map[string]any{"storage_vip_expire_at": nil, "storage_plan": "", "storage_plan_name": "", "storage_quota_mb": 0}).Error; err != nil {
t.Fatal(err)
}
create := func(key string) model.StorageOrder {
r := request("storage_create_order", "POST", token, M{"plan_key": key, "amount_cents": 99999999, "quota_mb": 99999999}, 200)
var o model.StorageOrder
if e := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; e != nil {
t.Fatal(e)
}
return o
}
deliver := func(o model.StorageOrder) {
tx := &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("tiers-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}}
if err := deliverWechatStorageOrder(db, tx); err != nil {
t.Fatal(err)
}
if err := deliverWechatStorageOrder(db, tx); err != nil {
t.Fatal(err)
}
}
order := create(small)
if order.AmountCents != 1 || order.PlanQuotaMb != 1024 || order.PlanMonths != 1 || order.PlanName != "1GB 测试" {
t.Fatalf("wrong snapshot: %+v", order)
}
// Change catalog after prepay; paid grant must still use the original snapshot.
if err := db.ORM.Model(&model.StoragePlan{}).Where("key=?", small).Updates(map[string]any{"quota_mb": 99, "months": 9, "price_cents": 500, "enabled": false}).Error; err != nil {
t.Fatal(err)
}
deliver(order)
space, err := store.StorageSpace(db.ORM, uid)
if err != nil || space.Unlimited || !space.IsVip || space.QuotaMb != 1024 {
t.Fatalf("finite allowance: %+v %v", space, err)
}
partner := request("storage_info", "GET", partnerToken, nil, 200)
if store.ToInt(partner["quota_mb"]) != 1024 || partner["is_unlimited"] != false {
t.Fatalf("partner allowance mismatch: %+v", partner)
}
if err := store.CheckFileQuota(db.ORM, uid, model.File{Size: 1025 * 1024 * 1024}); !errors.Is(err, store.ErrUploadQuota) {
t.Fatalf("paid finite upload bypassed limit: %v", err)
}
var count int64
db.ORM.Model(&model.StorageEntitlement{}).Where("order_id=?", order.Id).Count(&count)
if count != 1 {
t.Fatal("replayed callback duplicated capacity")
}
larger := create(large)
deliver(larger)
space, err = store.StorageSpace(db.ORM, uid)
if err != nil || space.QuotaMb != 5120 || space.Unlimited {
t.Fatalf("tier upgrade: %+v %v", space, err)
}
// Same capacity renewal starts after the previous grant; changing capacity does not replace it.
renewal := create(large)
deliver(renewal)
var first, next model.StorageEntitlement
db.ORM.Where("order_id=?", larger.Id).First(&first)
db.ORM.Where("order_id=?", renewal.Id).First(&next)
if !next.StartsAt.Equal(first.ExpiresAt) || !next.ExpiresAt.Equal(next.StartsAt.AddDate(0, 3, 0)) {
t.Fatal("same capacity renewal did not extend correctly")
}
if err := db.ORM.Model(&model.StorageEntitlement{}).Where("user_id=? AND quota_mb=?", uid, 5120).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
space, err = store.StorageSpace(db.ORM, uid)
if err != nil || space.QuotaMb != 1024 {
t.Fatalf("tier expiry did not fall back: %+v %v", space, err)
}
if err := db.ORM.Model(&model.StorageEntitlement{}).Where("user_id=?", uid).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
space, err = store.StorageSpace(db.ORM, uid)
if err != nil || space.IsVip || space.QuotaMb != 200 || space.Unlimited {
t.Fatalf("expired membership did not return to free quota: %+v %v", space, err)
}
// Keep the remaining general API fixtures independent of this catalog test.
current := request("admin_storage_settings", "GET", "admin", nil, 200)
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": []M{}, "revision": current["revision"]}, 200)
if plans, err := storageCatalog(db.ORM, true); err != nil || len(plans) != 0 {
t.Fatal("empty catalog not preserved")
}
// Restore original soft-deleted fixtures only inside this isolated test schema.
_ = db.ORM.Unscoped().Model(&model.StoragePlan{}).Where("key IN ?", []string{"month", "year"}).Update("deleted_at", nil).Error
t.Log("multi-tier catalog, 1-cent snapshots, finite quotas, partner sharing, renewals, expiry and replay verified")
}
+18 -23
View File
@@ -16,8 +16,8 @@ func registerTimeline(r *httpapi.Router) {
} }
// requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400); // requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) { func requireCoupleUserIds(c *httpapi.Ctx) ([]string, error) {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, "")
if len(ids) == 0 { if len(ids) == 0 {
return nil, c.Err(400, "请先绑定情侣") return nil, c.Err(400, "请先绑定情侣")
} }
@@ -25,10 +25,10 @@ func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) {
} }
// requireCoupleID 对应 $cid = getCoupleId(); if (!$cid) jsonResponse(['error' => '请先绑定情侣'], 400); // requireCoupleID 对应 $cid = getCoupleId(); if (!$cid) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleID(c *httpapi.Ctx) (int64, error) { func requireCoupleID(c *httpapi.Ctx) (string, error) {
cid := c.CoupleID() cid := c.CoupleID()
if cid == 0 { if cid == "" {
return 0, c.Err(400, "请先绑定情侣") return "", c.Err(400, "请先绑定情侣")
} }
return cid, nil return cid, nil
} }
@@ -42,7 +42,7 @@ func checkTextOr400(c *httpapi.Ctx, text string) error {
} }
func timelineList(c *httpapi.Ctx) error { func timelineList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := albumUserIds(c)
if len(ids) == 0 { if len(ids) == 0 {
return demoListResponse(c, "timeline", nil) return demoListResponse(c, "timeline", nil)
} }
@@ -55,7 +55,7 @@ func timelineList(c *httpapi.Ctx) error {
it["images"] = jsonDecodeList(it["images"]) it["images"] = jsonDecodeList(it["images"])
} }
total := countC(c, "SELECT COUNT(*) as c FROM timeline WHERE created_by IN (?, ?)", ids[0], ids[1]) total := countC(c, "SELECT COUNT(*) as c FROM timeline WHERE created_by IN (?, ?)", ids[0], ids[1])
upcoming, err := c.DB.QueryOne("SELECT * FROM timeline WHERE created_by IN (?, ?) AND event_date >= CURRENT_DATE ORDER BY event_date ASC, id DESC LIMIT 1", ids[0], ids[1]) upcoming, err := c.DB.QueryOne("SELECT * FROM timeline WHERE created_by IN (?, ?) AND event_date >= TO_CHAR(CURRENT_DATE,'YYYY-MM-DD') ORDER BY event_date ASC, id DESC LIMIT 1", ids[0], ids[1])
if err != nil { if err != nil {
return err return err
} }
@@ -66,23 +66,20 @@ func timelineAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
cid, err := requireCoupleID(c) cid := c.CoupleID()
if err != nil {
return err
}
uid := c.UID() uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err return err
} }
id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""), nullableCoupleID(cid), c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""),
jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid) jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid)
if err != nil { if err != nil {
return err return err
} }
title := c.InStr("title") title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "timeline_create", "添加了记忆日「"+title+"」", "") actID := logActivity(c.DB, cid, uid, "timeline_create", "添加了记忆日「"+title+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "记忆日更新", "thing1": "记忆日更新",
"thing2": "TA记录了「" + mbTruncate(title, 20) + "」", "thing2": "TA记录了「" + mbTruncate(title, 20) + "」",
@@ -97,10 +94,13 @@ func timelineUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { if item, err := c.DB.QueryOne("SELECT id FROM timeline WHERE id=? AND created_by IN (?,?)", c.In("id"), ids[0], ids[1]); err != nil {
return err return err
} else if item == nil {
return c.Err(404, "记忆日不存在或无权编辑")
} }
var err error
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err return err
} }
@@ -117,10 +117,7 @@ func timelineDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err return err
} }
@@ -131,10 +128,8 @@ func timelineDetail(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil { if err := c.RequireGET(); err != nil {
return err return err
} }
ids, err := requireCoupleUserIds(c) ids := albumUserIds(c)
if err != nil { var err error
return err
}
id := c.Q("id") id := c.Q("id")
if id == "" || id == "0" { if id == "" || id == "0" {
return c.Err(400, "缺少ID") return c.Err(400, "缺少ID")
@@ -0,0 +1,43 @@
package handler
import (
"encoding/hex"
"errors"
"strings"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
)
// This is advisory: actual upload recalculates the digest and byte count under
// the shared-space quota lock before writing anything to MinIO.
func uploadPreflight(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
size := c.InInt("size")
digest := strings.ToLower(c.InTrim("md5"))
raw, err := hex.DecodeString(digest)
if size <= 0 || err != nil || len(raw) != 16 {
return c.Err(400, "文件大小或MD5无效")
}
if c.App.Files == nil {
return c.Err(503, "文件服务暂不可用")
}
if size > c.App.Files.MaxSize {
return c.Err(413, "文件超过上传大小限制")
}
file := model.File{Size: size}
err = c.DB.ORM.WithContext(c.R.Context()).Where("bucket=? AND md5=? AND size=?", c.App.Files.Bucket, digest, size).Find(&file).Error
if err != nil {
return err
}
if err := store.CheckFileQuota(c.DB.ORM, c.UID(), file); err != nil {
if errors.Is(err, store.ErrUploadQuota) {
return c.ErrCode(403, "剩余存储空间不足,请购买更大容量的套餐后继续上传", ErrStorageQuotaExceeded)
}
return err
}
return c.OK(M{"success": true})
}
+35 -46
View File
@@ -5,8 +5,6 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"math" "math"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -45,14 +43,6 @@ func last4(s string) string {
return s[len(s)-4:] return s[len(s)-4:]
} }
// ensureUserDirs 创建用户独立文件夹 users/{uid}/{photos,diaries,covers,timeline}
func ensureUserDirs(uid int64) {
base := filepath.Join(up.Dir, "users", fmt.Sprint(uid))
for _, t := range []string{"photos", "diaries", "covers", "timeline"} {
os.MkdirAll(filepath.Join(base, t), 0755)
}
}
func registerUser(r *httpapi.Router) { func registerUser(r *httpapi.Router) {
r.Handle("register", register) r.Handle("register", register)
r.Handle("register_by_invite", registerByInvite) r.Handle("register_by_invite", registerByInvite)
@@ -151,7 +141,7 @@ func registerByInvite(c *httpapi.Ctx) error {
if link == nil { if link == nil {
return c.Err(400, "邀请链接无效或已使用") return c.Err(400, "邀请链接无效或已使用")
} }
if link.Int("inviter_couple_id") != 0 { if link.Str("inviter_couple_id") != "" {
return c.Err(400, "邀请人已绑定情侣") return c.Err(400, "邀请人已绑定情侣")
} }
displayID, err := generateUserId(c.DB) displayID, err := generateUserId(c.DB)
@@ -172,7 +162,7 @@ func registerByInvite(c *httpapi.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
inviterID := link.Int("user_id") inviterID := link.Str("user_id")
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID) coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID)
if err != nil { if err != nil {
return err return err
@@ -181,12 +171,10 @@ func registerByInvite(c *httpapi.Ctx) error {
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil { if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil {
return err return err
} }
ensureUserDirs(inviterID)
ensureUserDirs(newUserID)
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil { if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil {
return err return err
} }
if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Int("id")); err != nil { if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Str("id")); err != nil {
return err return err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -222,14 +210,14 @@ func login(c *httpapi.Ctx) error {
return c.Err(401, "用户名或密码错误") return c.Err(401, "用户名或密码错误")
} }
token := httpapi.NewToken() token := httpapi.NewToken()
uid := user.Int("id") uid := user.Str("id")
c.SaveUserToken(uid, token) c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if loc := resolveLoginLocation(c, ""); loc != "" { if loc := resolveLoginLocation(c, ""); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid) c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
} }
if user.Int("couple_id") != 0 { if user.Str("couple_id") != "" {
logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "") logActivity(c.DB, user.Str("couple_id"), uid, "login", "登录了小程序", "")
} }
return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"], return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"],
"couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token}) "couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token})
@@ -258,7 +246,7 @@ func wechatLogin(c *httpapi.Ctx) error {
return err return err
} }
if user != nil { if user != nil {
uid := user.Int("id") uid := user.Str("id")
token := httpapi.NewToken() token := httpapi.NewToken()
c.SaveUserToken(uid, token) c.SaveUserToken(uid, token)
if sessionKey != "" { if sessionKey != "" {
@@ -274,9 +262,9 @@ func wechatLogin(c *httpapi.Ctx) error {
if loc := resolveLoginLocation(c, region); loc != "" { if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid) c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
} }
if cid := user.Int("couple_id"); cid != 0 { if cid := user.Str("couple_id"); cid != "" {
actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "") actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
nick := user.Str("nickname") nick := user.Str("nickname")
if nick == "" { if nick == "" {
nick = "你的TA" nick = "你的TA"
@@ -291,7 +279,8 @@ func wechatLogin(c *httpapi.Ctx) error {
} }
return c.OK(M{ return c.OK(M{
"success": true, "is_new": false, "success": true, "is_new": false,
"user_id": user["id"], "display_id": user["user_id"], "data_revision": c.DataRevisionFor(uid, user.Str("couple_id")),
"user_id": user["id"], "display_id": user["user_id"],
"couple_id": user["couple_id"], "nickname": user["nickname"], "couple_id": user["couple_id"], "nickname": user["nickname"],
"avatar": user.Str("avatar"), "gender": user.Str("gender"), "avatar": user.Str("avatar"), "gender": user.Str("gender"),
"birthday": user.Str("birthday"), "region": user.Str("region"), "birthday": user.Str("birthday"), "region": user.Str("region"),
@@ -317,7 +306,8 @@ func wechatLogin(c *httpapi.Ctx) error {
c.SaveUserToken(newID, token) c.SaveUserToken(newID, token)
return c.OK(M{ return c.OK(M{
"success": true, "is_new": true, "success": true, "is_new": true,
"user_id": fmt.Sprint(newID), "display_id": displayID, "data_revision": c.DataRevisionFor(fmt.Sprint(newID), ""),
"user_id": fmt.Sprint(newID), "display_id": displayID,
"couple_id": nil, "nickname": defaultNick, "couple_id": nil, "nickname": defaultNick,
"avatar": "", "gender": "", "birthday": "", "region": region, "avatar": "", "gender": "", "birthday": "", "region": region,
"token": token, "token": token,
@@ -330,27 +320,27 @@ func checkAuth(c *httpapi.Ctx) error {
} }
c.CoupleID() c.CoupleID()
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.OK(M{"logged_in": false}) return c.OK(M{"logged_in": false})
} }
// 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同 // 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同
if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil { if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil {
if needLoginRecord(user.Str("last_login_at")) { if needLoginRecord(user.Str("last_login_at")) {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 { if user.Str("couple_id") != "" {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid) c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Str("couple_id"), uid)
} }
} }
} }
user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid) user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id, smoking_card_visible FROM users WHERE id = ?", uid)
if err != nil { if err != nil {
return err return err
} }
return c.OK(M{"logged_in": true, "user": user}) return c.OK(M{"logged_in": true, "user": user, "data_revision": c.DataRevision()})
} }
func logout(c *httpapi.Ctx) error { func logout(c *httpapi.Ctx) error {
if uid := c.UID(); uid != 0 { if uid := c.UID(); uid != "" {
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理 // PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token()) c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
} }
@@ -366,8 +356,8 @@ func myInfo(c *httpapi.Ctx) error {
if user == nil { if user == nil {
return c.OK(false) return c.OK(false)
} }
if user.Int("couple_id") != 0 { if user.Str("couple_id") != "" {
partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Int("couple_id"), uid) partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Str("couple_id"), uid)
if err != nil { if err != nil {
return err return err
} }
@@ -436,7 +426,7 @@ func updateProfile(c *httpapi.Ctx) error {
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil { if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err return err
} }
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 { if cid := c.CoupleID(); cid != "" && len(changed) > 0 {
logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "") logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "")
} }
} }
@@ -475,7 +465,7 @@ func updateLocation(c *httpapi.Ctx) error {
return err return err
} }
var distance any var distance any
if cid := c.CoupleID(); cid != 0 { if cid := c.CoupleID(); cid != "" {
partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid) partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid)
if partner != nil && lat != 0 && lng != 0 { if partner != nil && lat != 0 && lng != 0 {
distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"])) distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"]))
@@ -528,12 +518,12 @@ func updateIPLocation(c *httpapi.Ctx) error {
} }
// verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应 // verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应
func verifyPartner(c *httpapi.Ctx, uid, partnerID int64) error { func verifyPartner(c *httpapi.Ctx, uid, partnerID string) error {
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user == nil || user.Int("couple_id") == 0 { if user == nil || user.Str("couple_id") == "" {
return c.Err(400, "你还没有绑定情侣") return c.Err(400, "你还没有绑定情侣")
} }
partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Int("couple_id")) partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Str("couple_id"))
if partner == nil { if partner == nil {
return c.Err(400, "无法修改此用户信息") return c.Err(400, "无法修改此用户信息")
} }
@@ -545,7 +535,7 @@ func updatePartnerProfile(c *httpapi.Ctx) error {
return err return err
} }
uid := c.UID() uid := c.UID()
partnerID := c.InInt("partner_id") partnerID := c.InTrim("partner_id")
nickname := c.InTrim("nickname") nickname := c.InTrim("nickname")
avatar := c.InStr("avatar") avatar := c.InStr("avatar")
gender := c.InTrim("gender") gender := c.InTrim("gender")
@@ -589,7 +579,7 @@ func updatePartnerProfile(c *httpapi.Ctx) error {
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil { if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err return err
} }
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 { if cid := c.CoupleID(); cid != "" && len(changed) > 0 {
logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "") logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "")
} }
} }
@@ -597,12 +587,7 @@ func updatePartnerProfile(c *httpapi.Ctx) error {
} }
// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file // doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file
func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error { func doAvatarUpload(c *httpapi.Ctx, targetUID string) error {
u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", targetUID)
uidPath := fmt.Sprintf("uid%d", targetUID)
if u != nil {
uidPath = u.Str("user_id")
}
fh := c.File("avatar") fh := c.File("avatar")
if fh == nil { if fh == nil {
fh = c.File("file") fh = c.File("file")
@@ -610,13 +595,17 @@ func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error {
if fh == nil { if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
} }
res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath)) res := up.HandleFor(fh, targetUID)
if res.Path != "" { if res.Path != "" {
c.UploadResult = &res
if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil { if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil {
return err return err
} }
return c.OK(M{"success": true, "path": res.Path}) return c.OK(M{"success": true, "path": res.Path})
} }
if res.Code == upload.ErrStorageQuotaExceeded {
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
}
if strings.Contains(res.Error, "违规") { if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed) return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
} }
@@ -639,7 +628,7 @@ func uploadPartnerAvatar(c *httpapi.Ctx) error {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
} }
uid := c.UID() uid := c.UID()
partnerID := store.ToInt(c.Form("partner_id")) partnerID := c.Form("partner_id")
if err := verifyPartner(c, uid, partnerID); err != nil { if err := verifyPartner(c, uid, partnerID); err != nil {
return err return err
} }
+279
View File
@@ -0,0 +1,279 @@
package handler
import (
"context"
"fmt"
"go.uber.org/zap"
"net/http"
"time"
"unicode/utf8"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
"past/internal/wxpay"
)
func storageWechatOrderCreate(c *httpapi.Ctx, plan model.StoragePlan) error {
price := plan.PriceCents
if c.App.WechatPay == nil {
return c.Err(503, "微信支付服务未配置")
}
if price <= 0 || price > 100000000 {
return c.Err(400, "套餐价格无效")
}
uid := c.UID()
openID := userOpenId(c.DB, uid)
if openID == "" {
return c.Err(400, "请先使用微信登录后支付")
}
expires := time.Now().Add(30 * time.Minute)
cfg := c.App.Cfg.WechatPay
order := model.StorageOrder{UserId: uid, OutTradeNo: "P" + httpapi.NewToken()[:31], PlanKey: plan.Key, PlanName: plan.Name, PlanQuotaMb: plan.QuotaMb, PlanMonths: plan.Months, AmountCents: price, Status: "pending", PayProvider: "wechat", PayAppId: cfg.AppID, PayMchId: cfg.MchID, PayerOpenId: openID, ExpiresAt: &expires}
if cid := c.CoupleID(); cid != "" {
order.CoupleId = &cid
}
// Persist before calling WeChat: a lost prepay response can still be reconciled.
if err := c.DB.ORM.Create(&order).Error; err != nil {
return err
}
description := "我们past-" + plan.Name
for len(description) > 127 {
_, size := utf8.DecodeLastRuneInString(description)
description = description[:len(description)-size]
}
res, err := c.App.WechatPay.Prepay(c.R.Context(), wxpay.PrepayOrder{Number: order.OutTradeNo, Description: description, OpenID: openID, Amount: price, Attach: order.Id, ExpiresAt: expires})
if err != nil {
zap.S().Errorw("微信支付预下单失败", "out_trade_no", order.OutTradeNo, "err", err)
return c.Err(502, "微信支付下单失败,请稍后重试")
}
if res == nil || res.PrepayId == nil || res.Package == nil || res.PaySign == nil || res.TimeStamp == nil || res.NonceStr == nil || res.SignType == nil {
return c.Err(502, "微信支付返回参数不完整")
}
if err := c.DB.ORM.Model(&model.StorageOrder{}).Where("id=?", order.Id).Update("prepay_id", *res.PrepayId).Error; err != nil {
return err
}
return c.OK(M{"out_trade_no": order.OutTradeNo, "pay_provider": "wechat", "payment": M{"time_stamp": *res.TimeStamp, "nonce_str": *res.NonceStr, "package": *res.Package, "sign_type": *res.SignType, "pay_sign": *res.PaySign}})
}
func wechatPayNotify(c *httpapi.Ctx) error {
reply := func(status int, code, msg string) error {
return c.Raw(status, "application/json; charset=utf-8", []byte(fmt.Sprintf(`{"code":%q,"message":%q}`, code, msg)))
}
if c.Method != http.MethodPost {
return reply(405, "FAIL", "仅支持 POST")
}
if c.App.WechatPay == nil {
return reply(503, "FAIL", "支付服务未配置")
}
c.R.Body = http.MaxBytesReader(c.W, c.R.Body, 1<<20)
tx, err := c.App.WechatPay.ParseNotify(c.R.Context(), c.R)
if err != nil {
zap.S().Warnw("微信支付通知验签或解密失败", "err", err)
return reply(400, "FAIL", "通知验证失败")
}
if err := deliverWechatStorageOrder(c.DB, tx); err != nil {
zap.S().Errorw("微信支付通知处理失败", "err", err)
return reply(500, "FAIL", "订单处理失败")
}
// Only acknowledge after the transaction commits; WeChat retries failed responses.
return c.Raw(204, "application/json", nil)
}
func coreValue(v *string) string {
if v == nil {
return ""
}
return *v
}
func deliverWechatStorageOrder(db *store.DB, tx *payments.Transaction) error {
if tx == nil || coreValue(tx.OutTradeNo) == "" || coreValue(tx.TradeState) != "SUCCESS" {
return fmt.Errorf("无效的支付成功结果")
}
return db.ORM.Transaction(func(g *gorm.DB) error {
var order model.StorageOrder
if err := g.Clauses(clause.Locking{Strength: "UPDATE"}).Where("out_trade_no=?", *tx.OutTradeNo).First(&order).Error; err != nil {
return err
}
if order.PayProvider != "wechat" || coreValue(tx.Appid) != order.PayAppId || coreValue(tx.Mchid) != order.PayMchId || order.PayAppId == "" || order.PayMchId == "" || coreValue(tx.TradeType) != "JSAPI" || tx.Amount == nil || tx.Amount.Total == nil || *tx.Amount.Total != order.AmountCents || coreValue(tx.Amount.Currency) != "CNY" || tx.Payer == nil || coreValue(tx.Payer.Openid) != order.PayerOpenId || order.PayerOpenId == "" || coreValue(tx.TransactionId) == "" {
return fmt.Errorf("微信支付身份、币种或订单金额不匹配")
}
paidAt, err := time.Parse(time.RFC3339, coreValue(tx.SuccessTime))
if err != nil {
return fmt.Errorf("无效的支付时间")
}
if order.Status == "success" {
if order.WxOrderId != *tx.TransactionId {
return fmt.Errorf("订单已由其他交易支付")
}
return nil
}
// Serialize duplicate transaction IDs across different local orders.
if err := g.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", "wxpay:"+*tx.TransactionId).Error; err != nil {
return err
}
var count int64
if err := g.Model(&model.StorageOrder{}).Where("wx_order_id=? AND pay_provider='wechat' AND id<>?", *tx.TransactionId, order.Id).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("微信交易号已处理")
}
if err := grantStorageMembership(g, &order); err != nil {
return err
}
return g.Model(&order).Updates(map[string]any{"status": "success", "wx_order_id": *tx.TransactionId, "paid_at": paidAt}).Error
})
}
func grantStorageMembership(tx *gorm.DB, order *model.StorageOrder) error {
months, quota, name := order.PlanMonths, order.PlanQuotaMb, order.PlanName
if months == 0 {
switch order.PlanKey {
case "month":
months = 1
case "year":
months = 12
default:
return fmt.Errorf("无效的订单套餐快照")
}
quota = 0
name = storagePlanLabel(order.PlanKey)
}
if months < 1 || months > 120 || quota < 0 || quota > 104857600 || name == "" {
return fmt.Errorf("无效的订单套餐快照")
}
var user model.User
rUser := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", order.UserId).Limit(1).Find(&user)
if rUser.Error != nil {
return rUser.Error
}
if rUser.RowsAffected != 1 {
return fmt.Errorf("订单用户不存在")
}
now := time.Now()
// Preserve an old unlimited membership before changing its denormalized fields.
if (user.StoragePlan == "" || user.StoragePlan == "month" || user.StoragePlan == "year") && user.StorageVipExpireAt != nil && user.StorageVipExpireAt.After(now) {
var count int64
if err := tx.Model(&model.StorageEntitlement{}).Where("user_id=?", user.Id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
legacy := model.StorageEntitlement{UserId: user.Id, SourceKey: "legacy:" + user.Id, PlanKey: user.StoragePlan, PlanName: "原无限空间会员", StartsAt: now, ExpiresAt: *user.StorageVipExpireAt}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&legacy).Error; err != nil {
return err
}
}
}
start := now
var previous model.StorageEntitlement
r := tx.Where("user_id=? AND quota_mb=? AND expires_at>?", user.Id, quota, now).Order("expires_at DESC").Limit(1).Find(&previous)
if r.Error != nil {
return r.Error
}
if r.RowsAffected > 0 {
start = previous.ExpiresAt
}
expires := storageExpiry(start, months)
ent := model.StorageEntitlement{UserId: user.Id, SourceKey: "order:" + order.Id, PlanKey: order.PlanKey, PlanName: name, QuotaMb: quota, StartsAt: start, ExpiresAt: expires}
if store.ValidUUID(order.Id) {
ent.OrderId = &order.Id
}
if err := tx.Create(&ent).Error; err != nil {
return err
}
return tx.Model(&user).Updates(map[string]any{"storage_plan": order.PlanKey, "storage_plan_name": name, "storage_plan_started_at": now, "storage_vip_expire_at": expires}).Error
}
func storageOrderStatus(c *httpapi.Ctx) error {
number := c.Q("out_trade_no")
if number == "" {
number = c.InTrim("out_trade_no")
}
var order model.StorageOrder
result := c.DB.ORM.Where("out_trade_no=? AND user_id=?", number, c.UID()).Limit(1).Find(&order)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return c.Err(404, "订单不存在")
}
if order.PayProvider == "wechat" && order.Status == "pending" {
if c.App.WechatPay == nil {
return c.Err(503, "微信支付服务未配置")
}
if err := reconcileWechatOrder(c.R.Context(), c.DB, c.App.WechatPay, order.OutTradeNo); err != nil {
zap.S().Warnw("微信支付主动查单失败", "out_trade_no", order.OutTradeNo, "err", err)
return c.Err(502, "支付结果暂未确认,请稍后重试")
}
if err := c.DB.ORM.First(&order, "id=?", order.Id).Error; err != nil {
return err
}
}
return c.OK(M{"out_trade_no": order.OutTradeNo, "status": order.Status, "pay_provider": order.PayProvider, "paid_at": order.PaidAt})
}
func reconcileWechatOrder(ctx context.Context, db *store.DB, gateway wxpay.Gateway, number string) error {
tx, err := gateway.Query(ctx, number)
if err != nil {
return err
}
if tx == nil || coreValue(tx.OutTradeNo) != number {
return fmt.Errorf("查单返回订单号不匹配")
}
if coreValue(tx.TradeState) == "SUCCESS" {
return deliverWechatStorageOrder(db, tx)
}
if coreValue(tx.TradeState) == "CLOSED" || coreValue(tx.TradeState) == "REVOKED" || coreValue(tx.TradeState) == "PAYERROR" {
return db.ORM.Model(&model.StorageOrder{}).Where("out_trade_no=? AND pay_provider='wechat' AND status='pending'", number).Update("status", "closed").Error
}
if coreValue(tx.TradeState) != "NOTPAY" && coreValue(tx.TradeState) != "USERPAYING" {
return fmt.Errorf("未知的微信支付订单状态")
}
return nil
}
// RunWechatOrderQuery compensates for lost callbacks and stops with the server.
func RunWechatOrderQuery(db *store.DB, gateway wxpay.Gateway, stop <-chan struct{}, interval time.Duration) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-stop:
cancel()
case <-ctx.Done():
}
}()
if interval <= 0 {
interval = time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
var orders []model.StorageOrder
err := db.ORM.WithContext(ctx).Where("pay_provider='wechat' AND status='pending' AND created_at>NOW()-INTERVAL '24 hour'").Order("created_at ASC").Limit(50).Find(&orders).Error
if err != nil && ctx.Err() == nil {
zap.S().Errorw("微信支付待支付订单查询失败", "err", err)
}
for _, order := range orders {
if ctx.Err() != nil {
return
}
queryCtx, done := context.WithTimeout(ctx, 15*time.Second)
err := reconcileWechatOrder(queryCtx, db, gateway, order.OutTradeNo)
done()
if err != nil && ctx.Err() == nil {
zap.S().Warnw("微信支付补偿查单失败", "out_trade_no", order.OutTradeNo, "err", err)
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package handler
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"past/internal/config"
"past/internal/httpapi"
"past/internal/model"
"past/internal/store"
"past/internal/wxpay"
)
type paymentFake struct {
prepayErr error
query *payments.Transaction
notify *payments.Transaction
notifyErr error
last wxpay.PrepayOrder
}
func (f *paymentFake) Prepay(_ context.Context, o wxpay.PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
f.last = o
if f.prepayErr != nil {
return nil, f.prepayErr
}
return &jsapi.PrepayWithRequestPaymentResponse{PrepayId: core.String("prepay-test"), TimeStamp: core.String("123456"), NonceStr: core.String("nonce"), Package: core.String("prepay_id=prepay-test"), SignType: core.String("RSA"), PaySign: core.String("signature")}, nil
}
func (f *paymentFake) Query(context.Context, string) (*payments.Transaction, error) {
return f.query, nil
}
func (f *paymentFake) ParseNotify(context.Context, *http.Request) (*payments.Transaction, error) {
return f.notify, f.notifyErr
}
func exerciseWechatPayment(t *testing.T, db *store.DB, app *httpapi.App, engine http.Handler, request func(string, string, string, M, int) M, covered map[string]int, happy map[string]bool, uid, token, otherToken string) {
t.Helper()
oldCfg := app.Cfg.WechatPay
defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = nil }()
app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "wx-pay-test", MchID: "merchant-test"}
gateway := &paymentFake{}
app.WechatPay = gateway
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Updates(map[string]any{"openid": "pay-openid", "storage_vip_expire_at": time.Now().AddDate(0, -3, 0)}).Error; err != nil {
t.Fatal(err)
}
if err := db.ORM.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
t.Fatal(err)
}
create := func(plan string) model.StorageOrder {
action := "storage_create_order"
if plan == "year" {
action = "storage_order_create"
}
r := request(action, "POST", token, M{"plan_key": plan, "amount_cents": 1}, 200)
if r["pay_provider"] != "wechat" || r["payment"].(M)["package"] != "prepay_id=prepay-test" {
t.Fatalf("bad payment params: %+v", r)
}
var o model.StorageOrder
if err := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; err != nil {
t.Fatal(err)
}
if o.AmountCents != gateway.last.Amount || o.AmountCents <= 1 || o.PrepayId != "prepay-test" || o.PayerOpenId != "pay-openid" || o.PayAppId != "wx-pay-test" || o.PayMchId != "merchant-test" || o.PayProvider != "wechat" {
t.Fatalf("incorrect payment snapshot: %+v", o)
}
return o
}
order := create("month")
transaction := func(o model.StorageOrder) *payments.Transaction {
return &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("wx-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}}
}
request("storage_order_status", "GET", otherToken, M{"out_trade_no": order.OutTradeNo}, 404)
for _, field := range []string{"amount", "currency", "appid", "merchant", "payer", "state", "trade_type", "time", "nil_amount", "transaction"} {
tx := transaction(order)
switch field {
case "amount":
tx.Amount.Total = core.Int64(1)
case "currency":
tx.Amount.Currency = core.String("USD")
case "appid":
tx.Appid = core.String("foreign-app")
case "merchant":
tx.Mchid = core.String("foreign-merchant")
case "payer":
tx.Payer.Openid = core.String("foreign-payer")
case "state":
tx.TradeState = core.String("NOTPAY")
case "trade_type":
tx.TradeType = core.String("NATIVE")
case "time":
tx.SuccessTime = nil
case "nil_amount":
tx.Amount = nil
case "transaction":
tx.TransactionId = nil
}
if err := deliverWechatStorageOrder(db, tx); err == nil {
t.Fatalf("invalid %s accepted", field)
}
}
callback := func(want int) {
r := httptest.NewRequest("POST", "/api/wechatPayNotify", strings.NewReader(`{}`))
r.Header.Set("Content-Type", "application/json")
out := httptest.NewRecorder()
engine.ServeHTTP(out, r)
covered["wechat_pay_notify"]++
if out.Code != want {
t.Fatalf("wechat callback %d want %d: %s", out.Code, want, out.Body.String())
}
if out.Code == 204 {
happy["wechat_pay_notify"] = true
}
}
gateway.notifyErr = errors.New("bad signature")
callback(400)
gateway.notifyErr = nil
gateway.notify = transaction(order)
gateway.notify.Amount.Total = core.Int64(1)
callback(500)
var unpaid model.StorageOrder
db.ORM.First(&unpaid, "id=?", order.Id)
if unpaid.Status != "pending" {
t.Fatal("invalid notify changed status")
}
gateway.query = transaction(order)
gateway.query.TradeState = core.String("NOTPAY")
request("storage_order_status", "GET", token, M{"out_trade_no": order.OutTradeNo}, 200)
gateway.notify = transaction(order)
// Simultaneous authentic notifications are fulfilled exactly once.
results := make(chan error, 6)
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() { defer wg.Done(); results <- deliverWechatStorageOrder(db, transaction(order)) }()
}
wg.Wait()
close(results)
for err := range results {
if err != nil {
t.Fatal(err)
}
}
callback(204)
callback(204)
var user model.User
db.ORM.First(&user, "id=?", uid)
target := time.Now().AddDate(0, 1, 0)
if user.StorageVipExpireAt == nil || user.StorageVipExpireAt.Before(target.Add(-time.Minute)) || user.StorageVipExpireAt.After(target.Add(time.Minute)) || user.StoragePlan != "month" {
t.Fatalf("expired VIP renewal or idempotency failed: %+v", user.StorageVipExpireAt)
}
before := *user.StorageVipExpireAt
// Active query recovers a lost callback; requesting status again cannot extend VIP.
annual := create("year")
gateway.query = transaction(annual)
r := request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
if r["status"] != "success" {
t.Fatalf("query did not deliver: %+v", r)
}
request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
db.ORM.First(&user, "id=?", uid)
if !user.StorageVipExpireAt.Equal(before.AddDate(1, 0, 0)) {
t.Fatalf("annual membership wrong: %v", user.StorageVipExpireAt)
}
// A successful WeChat transaction cannot be applied to another order.
duplicate := create("month")
same := transaction(duplicate)
same.TransactionId = transaction(order).TransactionId
if err := deliverWechatStorageOrder(db, same); err == nil {
t.Fatal("duplicate WeChat transaction accepted")
}
gateway.query = transaction(duplicate)
gateway.query.TradeState = core.String("CLOSED")
r = request("storage_order_status", "GET", token, M{"out_trade_no": duplicate.OutTradeNo}, 200)
if r["status"] != "closed" {
t.Fatal("closed order not recorded")
}
// Transactional rollback if the owner has been deleted.
vanished := create("month")
var beforeDeleted model.User
db.ORM.First(&beforeDeleted, "id=?", uid)
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
if err := deliverWechatStorageOrder(db, transaction(vanished)); err == nil {
t.Fatal("deleted owner granted membership")
}
db.ORM.Unscoped().Model(&model.User{}).Where("id=?", uid).Update("deleted_at", nil)
var rolledBack model.StorageOrder
db.ORM.First(&rolledBack, "id=?", vanished.Id)
if rolledBack.Status != "pending" {
t.Fatal("failed grant marked paid")
}
// Search by order number, UUID, and public ID must all stay valid SQL.
var owner model.User
db.ORM.First(&owner, "id=?", uid)
for _, term := range []string{order.OutTradeNo, uid, owner.UserId} {
request("admin_storage_orders", "GET", "admin", M{"search": term}, 200)
}
t.Log(fmt.Sprintf("WeChat payment snapshots, validation, replay safety and query recovery verified"))
}
+20 -19
View File
@@ -25,39 +25,39 @@ func (c *Ctx) Token() string {
return "" return ""
} }
// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回 0。 // UID 对应 getUserId():通过 token 查 user_tokens;未登录返回空字符串。
func (c *Ctx) UID() int64 { func (c *Ctx) UID() string {
if c.uidResolved { if c.uidResolved {
return c.uid return c.uid
} }
c.uidResolved = true c.uidResolved = true
tok := c.Token() tok := c.Token()
if tok == "" { if tok == "" {
return 0 return ""
} }
row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok) row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok)
if err != nil || row == nil { if err != nil || row == nil {
return 0 return ""
} }
c.uid = row.Int("user_id") c.uid = row.Str("user_id")
return c.uid return c.uid
} }
// CoupleID 对应 getCoupleId();无则返回 0 // CoupleID 对应 getCoupleId();无则返回空字符串
func (c *Ctx) CoupleID() int64 { func (c *Ctx) CoupleID() string {
if c.coupleResolved { if c.coupleResolved {
return c.coupleID return c.coupleID
} }
c.coupleResolved = true c.coupleResolved = true
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return 0 return ""
} }
row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if row == nil { if row == nil {
return 0 return ""
} }
c.coupleID = row.Int("couple_id") c.coupleID = row.Str("couple_id")
return c.coupleID return c.coupleID
} }
@@ -71,15 +71,15 @@ func NewToken() string {
// SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。 // SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。
// PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录); // PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录);
// Go 没有 session 文件,因此保留旧 token 以维持同样的多端登录效果。 // Go 没有 session 文件,因此保留旧 token 以维持同样的多端登录效果。
func (c *Ctx) SaveUserToken(userID int64, token string) { func (c *Ctx) SaveUserToken(userID string, token string) {
c.DB.Exec("DELETE FROM user_tokens WHERE expires_at < NOW()") c.DB.Exec("DELETE FROM user_tokens WHERE expires_at < NOW()")
c.DB.Exec("INSERT INTO user_tokens (token, user_id, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))", token, userID) c.DB.Exec("INSERT INTO user_tokens (token, user_id, expires_at) VALUES (?, ?, (NOW() + INTERVAL '7 days'))", token, userID)
} }
// requireUserLogin 对应 requireUserLogin():401/404,并做 5 分钟去重的登录记录 // requireUserLogin 对应 requireUserLogin():401/404,并做 5 分钟去重的登录记录
func (c *Ctx) requireUserLogin() error { func (c *Ctx) requireUserLogin() error {
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == "" {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
if err := c.DB.AdvanceUnbindings(); err != nil { if err := c.DB.AdvanceUnbindings(); err != nil {
@@ -92,6 +92,7 @@ func (c *Ctx) requireUserLogin() error {
if user == nil { if user == nil {
return c.Err(404, "用户不存在") return c.Err(404, "用户不存在")
} }
c.G.Set("revisionCoupleID", user.Str("couple_id"))
needRecord := false needRecord := false
if user.IsNull("last_login_at") || user.Str("last_login_at") == "" { if user.IsNull("last_login_at") || user.Str("last_login_at") == "" {
needRecord = true needRecord = true
@@ -100,8 +101,8 @@ func (c *Ctx) requireUserLogin() error {
} }
if needRecord { if needRecord {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 { if user.Str("couple_id") != "" {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid) c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Str("couple_id"), uid)
} }
} }
return nil return nil
@@ -113,7 +114,7 @@ const adminCookieName = "PHPSESSID"
const adminSessionTTL = 12 * time.Hour const adminSessionTTL = 12 * time.Hour
type adminSession struct { type adminSession struct {
AdminID int64 AdminID string
Username string Username string
Expires time.Time Expires time.Time
} }
@@ -162,7 +163,7 @@ func (c *Ctx) AdminSession() *adminSession {
} }
// AdminLogin 建立管理员会话并写 Cookie(对应 session_start + $_SESSION['admin_logged_in']=true) // AdminLogin 建立管理员会话并写 Cookie(对应 session_start + $_SESSION['admin_logged_in']=true)
func (c *Ctx) AdminLogin(adminID int64, username string) { func (c *Ctx) AdminLogin(adminID string, username string) {
id := NewToken() id := NewToken()
c.App.Admin.mu.Lock() c.App.Admin.mu.Lock()
c.App.Admin.m[id] = &adminSession{AdminID: adminID, Username: username, Expires: time.Now().Add(adminSessionTTL)} c.App.Admin.m[id] = &adminSession{AdminID: adminID, Username: username, Expires: time.Now().Add(adminSessionTTL)}
@@ -189,4 +190,4 @@ func (c *Ctx) requireAdminLogin() error {
return nil return nil
} }
func (c *Ctx) SetCoupleID(cid int64) { c.coupleID = cid; c.coupleResolved = true } func (c *Ctx) SetCoupleID(cid string) { c.coupleID = cid; c.coupleResolved = true }
+32 -16
View File
@@ -16,36 +16,41 @@ import (
"past/internal/config" "past/internal/config"
"past/internal/geo" "past/internal/geo"
"past/internal/store" "past/internal/store"
"past/internal/upload"
"past/internal/wechat" "past/internal/wechat"
"past/internal/wxpay"
) )
// App 全局依赖 // App 全局依赖
type App struct { type App struct {
Cfg *config.Config WechatPay wxpay.Gateway
DB *store.DB Cfg *config.Config
Wechat *wechat.Client DB *store.DB
Geo *geo.Client Wechat *wechat.Client
Admin *AdminSessions Geo *geo.Client
Files *upload.Uploader
Admin *AdminSessions
} }
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。 // Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
type Ctx struct { type Ctx struct {
G *gin.Context UploadResult *upload.Result
W http.ResponseWriter G *gin.Context
R *http.Request W http.ResponseWriter
App *App R *http.Request
DB *store.DB App *App
Action string DB *store.DB
CamelCase bool Action string
Method string CamelCase bool
Method string
input map[string]any input map[string]any
inputParsed bool inputParsed bool
body []byte body []byte
bodyRead bool bodyRead bool
uid int64 uid string
uidResolved bool uidResolved bool
coupleID int64 coupleID string
coupleResolved bool coupleResolved bool
responded bool responded bool
} }
@@ -65,10 +70,20 @@ func (c *Ctx) JSON(code int, v any) error {
return errResponded return errResponded
} }
c.responded = true c.responded = true
if c.uidResolved && c.uid != 0 && c.coupleResolved { c.publishDataChange(code, v)
if c.uidResolved && c.uid != "" && c.coupleResolved {
c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID)) c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID))
c.G.Header("X-User-ID", fmt.Sprint(c.uid)) c.G.Header("X-User-ID", fmt.Sprint(c.uid))
} }
if c.UploadResult != nil && c.UploadResult.File != nil {
if payload, ok := v.(M); ok {
result := c.UploadResult
payload["file_id"] = result.FileId
payload["url"] = result.Path
payload["file"] = result.File
payload["duplicate"] = result.Duplicate
}
}
var buf bytes.Buffer var buf bytes.Buffer
enc := json.NewEncoder(&buf) enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
@@ -172,6 +187,7 @@ func (c *Ctx) Input() map[string]any {
m = renameKeys(m, snakeName).(map[string]any) m = renameKeys(m, snakeName).(map[string]any)
} }
c.input = normalizeNumbers(m).(map[string]any) c.input = normalizeNumbers(m).(map[string]any)
applyFileInputAliases(c.input, c.Action)
} }
} }
} }
+13
View File
@@ -45,3 +45,16 @@ func normalizeMediaInput(value any) any {
} }
return value return value
} }
// New clients submit UUIDs; old field aliases remain compatible at the HTTP boundary.
func applyFileInputAliases(input map[string]any, action string) {
aliases := map[string]string{"avatar_file_id": "avatar", "cover_file_id": "cover", "image_file_ids": "images", "bg_image_file_id": "bg_image", "my_photo_file_id": "my_photo", "partner_photo_file_id": "partner_photo", "photo_file_id": "photo"}
if action == "photo_add" {
aliases["file_id"] = "path"
}
for key, alias := range aliases {
if value, ok := input[key]; ok {
input[alias] = value
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) {
} }
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) { func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
resource := "uploads/users/1/diaries/a.jpg" resource := "uploads/users/1/diaries/a.jpg"
signed := "https://past.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode() signed := "https://we.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode()
input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"} input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"}
normalized := normalizeMediaInput(input).(map[string]any) normalized := normalizeMediaInput(input).(map[string]any)
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" { if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
+23
View File
@@ -0,0 +1,23 @@
package httpapi
import "strings"
// 记录功能仅供已绑定情侣的用户使用;资料、头像、存储套餐与邀请独立开放。
func recordAction(action string) bool {
for _, prefix := range []string{"diary_", "timeline_", "album_", "photo_", "songs_", "checkin_", "smoking_", "pet_", "period_"} {
if strings.HasPrefix(action, prefix) {
return true
}
}
return action == "photos" || action == "set_album_cover"
}
func (c *Ctx) requireRecordBinding() error {
if !recordAction(c.Action) {
return nil
}
if c.CoupleID() == "" {
return c.ErrCode(403, "请先绑定情侣,再使用记录功能", 10002)
}
return nil
}
@@ -0,0 +1,32 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"net/http/httptest"
"testing"
)
func TestRecordBindingAccess(t *testing.T) {
for _, action := range []string{"diary_list", "diary_add", "diary_comment_add", "timeline_detail", "album_add", "photos", "photo_upload", "set_album_cover", "songs_list", "checkin_do", "smoking_card_visible", "pet_adopt", "period_daily_save"} {
t.Run(action, func(t *testing.T) {
for _, cid := range []string{"", "bound-couple"} {
out := httptest.NewRecorder()
g, _ := gin.CreateTestContext(out)
c := &Ctx{G: g, Action: action, coupleResolved: true, coupleID: cid}
err := c.requireRecordBinding()
if cid == "" {
if !IsResponded(err) || out.Code != 403 {
t.Fatalf("unbound allowed: %v %d", err, out.Code)
}
} else if err != nil {
t.Fatal(err)
}
}
})
}
for _, action := range []string{"upload", "upload_avatar", "update_profile", "couple_info", "invite_partner", "storage_info", "account_delete", "announcement", "admin_contents"} {
if recordAction(action) {
t.Fatalf("unrelated feature blocked: %s", action)
}
}
}
+90
View File
@@ -0,0 +1,90 @@
package httpapi
import (
"context"
"crypto/sha256"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
var revisionMutation = regexp.MustCompile(`^(update_(profile|partner_profile|couple_info|location|ip_location)|upload(_avatar|_partner_avatar)?|timeline_(add|update|delete)|album_(add|update|delete)|photo_(add|update|delete|upload)|set_album_cover|diary_(add|update|delete|comment_add|comment_delete|like|favorite)|songs_(add|update|delete)|checkin_(add|do|uncheck|toggle|delete)|smoking_(action|card_visible)|pet_(adopt|daily_action|rename|equip)|period_(add|update|delete|visible_set|source_select|daily_save|sex_add|sex_delete)|set_bg_image|delete_bg_image|respond_invitation|accept_share_invite|unbind_(request|respond|cancel|couple)|account_delete|wechat_pay_notify|admin_(announcement|storage_settings|storage_user_update|update_user|delete_user|delete_content|set_global_bg|clear_global_bg|clear_couple_bg|force_unbind|restore_couple|delete_couple_data))$`)
func (c *Ctx) revisionKeys() []string {
cid := c.G.GetString("revisionCoupleID")
if cid == "" {
cid = c.coupleID
}
return c.revisionKeysFor(c.uid, cid)
}
func (c *Ctx) revisionKeysFor(uid, cid string) []string {
prefix := c.App.Wechat.KeyPrefix + "data-revision:"
keys := []string{prefix + "global"}
if uid != "" {
keys = append(keys, prefix+"user:"+uid)
}
if cid != "" {
keys = append(keys, prefix+"couple:"+cid)
}
return keys
}
// DataRevision is shared by a user and their partner, plus global admin changes.
// Redis failure leaves the bounded client TTL as a fallback, never failing data writes.
func (c *Ctx) DataRevision() string {
return c.DataRevisionFor(c.uid, c.G.GetString("revisionCoupleID"))
}
func (c *Ctx) DataRevisionFor(uid, cid string) string {
if c.App == nil || c.App.Wechat == nil || c.App.Wechat.Redis == nil {
return ""
}
ctx, cancel := context.WithTimeout(c.R.Context(), time.Second)
defer cancel()
values, err := c.App.Wechat.Redis.MGet(ctx, c.revisionKeysFor(uid, cid)...).Result()
if err != nil {
return ""
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprint(values))))
}
func (c *Ctx) publishDataChange(code int, value any) {
if c.App == nil || c.App.Wechat == nil || c.App.Wechat.Redis == nil || code < 200 || code >= 300 {
return
}
if payload, ok := value.(M); ok {
if payload["success"] == false || payload["error"] != nil {
return
}
}
if !revisionMutation.MatchString(c.Action) {
return
}
if c.Method != "POST" {
return
}
keys := c.revisionKeys()
// Relationship and administrative changes may affect a second user outside
// the initiating user's old scope. A global version keeps those clients safe.
if strings.HasPrefix(c.Action, "admin_") || strings.Contains(c.Action, "unbind") || c.Action == "respond_invitation" || c.Action == "accept_share_invite" || c.Action == "wechat_pay_notify" {
keys = keys[:1]
} else {
keys = keys[1:]
}
if len(keys) == 0 {
return
}
ctx, cancel := context.WithTimeout(c.R.Context(), time.Second)
defer cancel()
pipe := c.App.Wechat.Redis.TxPipeline()
version := uuid.NewString()
for _, key := range keys {
pipe.Set(ctx, key, version, 0)
}
if _, err := pipe.Exec(ctx); err != nil {
zap.S().Warnw("data revision update failed", "action", c.Action, "err", err)
}
}
+49
View File
@@ -0,0 +1,49 @@
package httpapi
import (
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"net/http/httptest"
"past/internal/wechat"
"testing"
)
func TestRevisionScopesAndSuccessfulWrites(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
defer client.Close()
makeCtx := func(uid, cid, action string) *Ctx {
g, _ := gin.CreateTestContext(httptest.NewRecorder())
g.Set("revisionCoupleID", cid)
return &Ctx{G: g, R: httptest.NewRequest("POST", "/api/"+action, nil), Method: "POST", Action: action, uid: uid, App: &App{Wechat: &wechat.Client{Redis: client, KeyPrefix: "test:"}}}
}
a, b, other := makeCtx("a", "couple", "diary_add"), makeCtx("b", "couple", ""), makeCtx("other", "elsewhere", "")
va, vb, vo := a.DataRevision(), b.DataRevision(), other.DataRevision()
a.publishDataChange(403, M{"error": "denied"})
if a.DataRevision() != va {
t.Fatal("failed write changed revision")
}
a.publishDataChange(200, M{"success": false})
if a.DataRevision() != va {
t.Fatal("business rejection changed revision")
}
a.publishDataChange(200, M{"success": true})
if a.DataRevision() == va || b.DataRevision() == vb {
t.Fatal("user and partner did not see committed mutation")
}
if other.DataRevision() != vo {
t.Fatal("unrelated couple was invalidated")
}
before := a.DataRevision()
a.Action = "diary_list"
a.publishDataChange(200, M{"items": []any{}})
if a.DataRevision() != before {
t.Fatal("read changed revision")
}
admin := makeCtx("", "", "admin_announcement")
admin.publishDataChange(200, M{"success": true})
if a.DataRevision() == before || other.DataRevision() == vo {
t.Fatal("admin change was not visible globally")
}
}
+77 -8
View File
@@ -3,9 +3,9 @@ package httpapi
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"go.uber.org/zap"
"io" "io"
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -17,6 +17,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"past/internal/model"
"past/internal/store"
"past/internal/upload"
) )
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束; // HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
@@ -41,6 +44,8 @@ func (r *Router) Handle(action string, h HandlerFunc) {
r.handlers[action] = h r.handlers[action] = h
} }
func (r *Router) SetFileStorage(files *upload.Uploader) { r.app.Files = files }
// Actions 已注册的 action 列表(用于自检) // Actions 已注册的 action 列表(用于自检)
func (r *Router) Actions() []string { func (r *Router) Actions() []string {
out := make([]string, 0, len(r.handlers)) out := make([]string, 0, len(r.handlers))
@@ -52,9 +57,9 @@ func (r *Router) Actions() []string {
// 与 index.php 中 $publicActions / $adminActions 完全一致 // 与 index.php 中 $publicActions / $adminActions 完全一致
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login", var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
"check_invite_code", "register_by_invite", "check_auth", "storage_pay_notify") "check_invite_code", "register_by_invite", "check_auth", "wechat_pay_notify")
var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders") var adminActions = set("admin_announcement", "admin_files", "admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders")
func set(items ...string) map[string]bool { func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items)) m := make(map[string]bool, len(items))
@@ -74,11 +79,68 @@ func (r *Router) Engine(debugMode bool) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
e := gin.New() e := gin.New()
e.Use(gin.Recovery(), accessLog()) gin.DefaultWriter = zap.NewStdLog(zap.L()).Writer()
gin.DefaultErrorWriter = zap.NewStdLog(zap.L()).Writer()
e.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) {
zap.S().Errorw("http panic", "err", recovered, "stack", string(debug.Stack()))
c.AbortWithStatus(http.StatusInternalServerError)
}), accessLog())
e.RedirectTrailingSlash = false e.RedirectTrailingSlash = false
e.MaxMultipartMemory = 32 << 20 e.MaxMultipartMemory = 32 << 20
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。 // 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
e.GET("/api/files/:fileId", func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if r.app.Files == nil || !store.ValidUUID(c.Param("fileId")) {
c.Status(404)
return
}
var file model.File
if err := r.app.DB.ORM.WithContext(c.Request.Context()).First(&file, "id=?", c.Param("fileId")).Error; err != nil {
c.Status(404)
return
}
etag := `"` + file.Md5 + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "private, max-age=3600")
c.Header("X-Content-Type-Options", "nosniff")
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
return
}
signed, err := r.app.Files.PresignedURL(c.Request.Context(), file.Id)
if err != nil {
c.Header("Cache-Control", "no-store")
c.Status(502)
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, signed, nil)
if err != nil {
c.Status(502)
return
}
if v := c.GetHeader("Range"); v != "" {
req.Header.Set("Range", v)
}
response, err := r.app.Files.HTTPClient.Do(req)
if err != nil {
c.Header("Cache-Control", "no-store")
c.Status(502)
return
}
defer response.Body.Close()
if response.StatusCode != 200 && response.StatusCode != 206 {
c.Header("Cache-Control", "no-store")
c.Status(502)
return
}
if v := response.Header.Get("Content-Range"); v != "" {
c.Header("Content-Range", v)
}
c.Header("Accept-Ranges", "bytes")
c.Header("Content-Disposition", "inline")
c.DataFromReader(response.StatusCode, response.ContentLength, file.ContentType, response.Body, nil)
})
e.Any("/api", r.serveAPI) e.Any("/api", r.serveAPI)
e.Any("/api/", r.serveAPI) e.Any("/api/", r.serveAPI)
e.Any("/api/:action", r.serveAPI) e.Any("/api/:action", r.serveAPI)
@@ -148,7 +210,7 @@ func (r *Router) serveAPI(g *gin.Context) {
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack())) zap.S().Errorw("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
if !c.responded { if !c.responded {
c.Err(500, fmt.Sprint(rec)) c.Err(500, fmt.Sprint(rec))
} }
@@ -179,13 +241,20 @@ func (r *Router) serveAPI(g *gin.Context) {
return return
} }
if err := c.requireRecordBinding(); err != nil {
return
}
if c.DB != nil {
c.DB = c.DB.WithActor(c.uid, adminActions[action] && action != "admin_login")
}
hf, ok := r.handlers[action] hf, ok := r.handlers[action]
if !ok { if !ok {
c.Err(400, "未知操作") c.Err(400, "未知操作")
return return
} }
if err := hf(c); err != nil && !IsResponded(err) { if err := hf(c); err != nil && !IsResponded(err) {
slog.Error("handler error", "action", action, "err", err) zap.S().Errorw("handler error", "action", action, "err", err)
c.Err(500, err.Error()) c.Err(500, err.Error())
} }
if !c.responded { if !c.responded {
@@ -194,7 +263,7 @@ func (r *Router) serveAPI(g *gin.Context) {
} }
} }
// accessLog gin 中间件:结构化访问日志(slog) // accessLog gin 中间件:结构化访问日志(zap)
func accessLog() gin.HandlerFunc { func accessLog() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()
@@ -206,7 +275,7 @@ func accessLog() gin.HandlerFunc {
if uid, ok := c.Get("uid"); ok { if uid, ok := c.Get("uid"); ok {
attrs = append(attrs, "uid", uid) attrs = append(attrs, "uid", uid)
} }
slog.Info("http", attrs...) zap.S().Infow("http", attrs...)
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ func TestCleanAPIRoutes(t *testing.T) {
router := NewRouter(app, fstest.MapFS{"index.html": {Data: []byte("admin")}}) router := NewRouter(app, fstest.MapFS{"index.html": {Data: []byte("admin")}})
router.Handle("site_info", func(c *Ctx) error { return c.JSON(200, M{"action": c.Action, "value": c.Q("value")}) }) router.Handle("site_info", func(c *Ctx) error { return c.JSON(200, M{"action": c.Action, "value": c.Q("value")}) })
router.Handle("admin_login", func(c *Ctx) error { router.Handle("admin_login", func(c *Ctx) error {
c.AdminLogin(1, "test") c.AdminLogin("1", "test")
return c.JSON(200, M{"success": true}) return c.JSON(200, M{"success": true})
}) })
router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) }) router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) })
+124
View File
@@ -0,0 +1,124 @@
// Package logging owns zap initialization, rotation and shutdown.
package logging
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
"past/internal/config"
)
// New writes JSON files in log/YYYY-MM-DD/{level}.log, and optionally to stdout.
func New(dir string, cfg config.LogConfig) (*zap.Logger, func(), error) {
var minimum zapcore.Level
if err := minimum.UnmarshalText([]byte(cfg.Level)); err != nil {
return nil, nil, fmt.Errorf("日志级别无效: %w", err)
}
if cfg.RetentionDays <= 0 || cfg.MaxSizeMB <= 0 || cfg.MaxBackups <= 0 {
return nil, nil, fmt.Errorf("日志保留天数、大小和备份数量必须大于零")
}
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, nil, err
}
enc := zap.NewProductionEncoderConfig()
enc.EncodeTime = zapcore.ISO8601TimeEncoder
cores := []zapcore.Core{}
writers := []*dailyWriter{}
for level := zapcore.DebugLevel; level <= zapcore.FatalLevel; level++ {
if level < minimum {
continue
}
w := &dailyWriter{dir: dir, level: level.String(), cfg: cfg, now: time.Now}
// Open eagerly so an unwritable directory fails startup instead of losing logs.
if err := w.rotate(); err != nil {
for _, old := range writers {
old.Close()
}
return nil, nil, err
}
writers = append(writers, w)
exact := level
cores = append(cores, zapcore.NewCore(zapcore.NewJSONEncoder(enc), zapcore.AddSync(w), zap.LevelEnablerFunc(func(l zapcore.Level) bool { return l == exact })))
}
if cfg.Console {
cores = append(cores, zapcore.NewCore(zapcore.NewConsoleEncoder(enc), zapcore.Lock(os.Stdout), minimum))
}
log := zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
cleanup := func() {
_ = log.Sync()
for _, w := range writers {
_ = w.Close()
}
}
return log, cleanup, nil
}
type dailyWriter struct {
mu sync.Mutex
dir, level, day string
cfg config.LogConfig
now func() time.Time
writer *lumberjack.Logger
}
// Caller holds mu (initialization happens before the writer is shared).
func (w *dailyWriter) rotate() error {
now := w.now()
day := now.Format(time.DateOnly)
if day == w.day && w.writer != nil {
return nil
}
path := filepath.Join(w.dir, day)
if err := os.MkdirAll(path, 0700); err != nil {
return err
}
filename := filepath.Join(path, w.level+".log")
f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
_ = f.Close()
if w.writer != nil {
_ = w.writer.Close()
}
w.writer = &lumberjack.Logger{Filename: filename, MaxSize: w.cfg.MaxSizeMB, MaxAge: w.cfg.RetentionDays, MaxBackups: w.cfg.MaxBackups, Compress: true, LocalTime: true}
w.day = day
entries, err := os.ReadDir(w.dir)
if err != nil {
return err
}
cutoff := now.AddDate(0, 0, -w.cfg.RetentionDays).Format(time.DateOnly)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := time.Parse(time.DateOnly, entry.Name()); err == nil && entry.Name() < cutoff {
if err := os.RemoveAll(filepath.Join(w.dir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotate(); err != nil {
return 0, err
}
return w.writer.Write(p)
}
func (w *dailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.writer != nil {
return w.writer.Close()
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package logging
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"past/internal/config"
)
func TestLevelFilesAndDailyRotation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "log")
cfg := config.LogConfig{Level: "info", RetentionDays: 30, MaxSizeMB: 1, MaxBackups: 2}
logger, closeLog, err := New(dir, cfg)
if err != nil {
t.Fatal(err)
}
logger.Info("started", zap.String("component", "redis"))
logger.Warn("retry")
logger.Error("failure")
logger.Debug("hidden")
closeLog()
for _, level := range []string{"info", "warn", "error"} {
data, err := os.ReadFile(filepath.Join(dir, time.Now().Format(time.DateOnly), level+".log"))
if err != nil {
t.Fatal(err)
}
var entry map[string]any
if err := json.Unmarshal(data, &entry); err != nil {
t.Fatalf("invalid structured log: %s", data)
}
if entry["level"] != level || entry["caller"] == nil {
t.Fatalf("missing level/caller: %s", data)
}
}
w := &dailyWriter{dir: dir, level: "info", cfg: cfg, now: func() time.Time { return time.Date(2026, 11, 1, 23, 59, 0, 0, time.Local) }}
defer w.Close()
if _, err := w.Write([]byte("before midnight\n")); err != nil {
t.Fatal(err)
}
w.now = func() time.Time { return time.Date(2026, 11, 2, 0, 1, 0, 0, time.Local) }
if _, err := w.Write([]byte("after midnight\n")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "2026-11-02", "info.log"))
if err != nil || !strings.Contains(string(data), "after midnight") {
t.Fatal("daily rotation failed", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package model
// File represents one physical object. References and upload ownership are independent.
type File struct {
BaseModel
Size int64 `gorm:"not null;check:size >= 0" json:"size"`
Suffix string `gorm:"size:20;not null" json:"suffix"`
ContentType string `gorm:"size:255;not null" json:"content_type"`
ObjectKey string `gorm:"size:1024;not null;uniqueIndex:ux_files_object,priority:2" json:"object_key"`
Md5 string `gorm:"size:32;not null;uniqueIndex:ux_files_md5,priority:2" json:"md5"`
Sha256 string `gorm:"size:64;not null" json:"-"`
Url string `gorm:"type:text;not null" json:"url"`
Bucket string `gorm:"size:255;not null;uniqueIndex:ux_files_md5,priority:1;uniqueIndex:ux_files_object,priority:1" json:"bucket"`
OriginalName string `gorm:"size:255" json:"original_name"`
}
func (File) TableName() string { return "past_files" }
// FileUpload records independent ownership of an upload, including deduplicated uploads.
type FileUpload struct {
BaseModel
UserId string `gorm:"type:uuid;not null;uniqueIndex:ux_file_upload,where:deleted_at IS NULL,priority:1" json:"user_id"`
FileId string `gorm:"type:uuid;not null;uniqueIndex:ux_file_upload,where:deleted_at IS NULL,priority:2;index" json:"file_id"`
User User `gorm:"foreignKey:UserId;references:Id;belongsTo:User;constraint:OnDelete:RESTRICT" json:"-"`
File File `gorm:"foreignKey:FileId;references:Id;constraint:OnDelete:RESTRICT" json:"-"`
}
// RecordFile is an ordered one-to-many link for diary/timeline images.
// OwnerTable is restricted to the supported polymorphic parents; creation and
// replacement are serialized with their parent transaction.
type RecordFile struct {
BaseModel
OwnerTable string `gorm:"size:50;not null;uniqueIndex:ux_record_file_position,where:deleted_at IS NULL,priority:1;check:owner_table IN ('diaries','timeline')" json:"owner_table"`
OwnerId string `gorm:"type:uuid;not null;uniqueIndex:ux_record_file_position,where:deleted_at IS NULL,priority:2" json:"owner_id"`
Field string `gorm:"size:50;not null;uniqueIndex:ux_record_file_position,where:deleted_at IS NULL,priority:3;check:field = 'images'" json:"field"`
Position int `gorm:"not null;uniqueIndex:ux_record_file_position,where:deleted_at IS NULL,priority:4;check:position >= 0" json:"position"`
FileId string `gorm:"type:uuid;not null;index" json:"file_id"`
File File `gorm:"foreignKey:FileId;references:Id;constraint:OnDelete:RESTRICT" json:"-"`
}
// Durable queue: object deletion is retried if MinIO is temporarily unavailable.
type FileDeletion struct {
BaseModel
FileId string `gorm:"type:uuid;not null;uniqueIndex" json:"file_id"`
}
+506
View File
@@ -0,0 +1,506 @@
// Package model defines the PostgreSQL schema shared by all repositories.
package model
import (
"gorm.io/gorm"
"time"
)
// BaseModel supplies identity, automatic timestamps and GORM soft deletion.
type BaseModel struct {
Id string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type UnbindingFileCleanup struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
Path string `gorm:"column:path;type:varchar(500);not null" json:"path"`
}
type User struct {
BaseModel
UserId string `gorm:"column:user_id;type:varchar(20);uniqueIndex:ux_users_user_id,where:deleted_at IS NULL" json:"user_id"`
Openid string `gorm:"column:openid;type:varchar(100);uniqueIndex:ux_users_openid,where:deleted_at IS NULL" json:"openid"`
Phone string `gorm:"column:phone;type:varchar(50);default:''" json:"phone"`
Username string `gorm:"column:username;type:varchar(100);default:'';uniqueIndex:ux_users_username,where:deleted_at IS NULL" json:"username"`
Password string `gorm:"column:password;type:varchar(255);default:''" json:"password"`
Nickname string `gorm:"column:nickname;type:varchar(100);default:''" json:"nickname"`
Avatar string `gorm:"column:avatar;type:varchar(500);default:''" json:"avatar"`
Gender string `gorm:"column:gender;type:varchar(10);default:''" json:"gender"`
CoupleId *string `gorm:"column:couple_id;type:uuid" json:"couple_id"`
LastLoginAt *time.Time `gorm:"column:last_login_at;type:timestamptz" json:"last_login_at"`
PeriodVisible int64 `gorm:"column:period_visible;type:bigint;not null;default:1" json:"period_visible"`
Age int64 `gorm:"column:age;type:bigint;default:0" json:"age"`
Birthday string `gorm:"column:birthday;type:varchar(20);default:''" json:"birthday"`
Region string `gorm:"column:region;type:varchar(100);default:''" json:"region"`
Location string `gorm:"column:location;type:varchar(200);default:''" json:"location"`
Lat float64 `gorm:"column:lat;type:numeric(10,6);default:0" json:"lat"`
Lng float64 `gorm:"column:lng;type:numeric(10,6);default:0" json:"lng"`
LastLoginLocation string `gorm:"column:last_login_location;type:varchar(200);default:''" json:"last_login_location"`
IpGeoDate *string `gorm:"column:ip_geo_date;type:date" json:"ip_geo_date"`
LastIp string `gorm:"column:last_ip;type:varchar(64);default:''" json:"last_ip"`
SessionKey string `gorm:"column:session_key;type:varchar(128);default:''" json:"session_key"`
StorageVipExpireAt *time.Time `gorm:"column:storage_vip_expire_at;type:timestamptz" json:"storage_vip_expire_at"`
StorageQuotaMb int64 `gorm:"column:storage_quota_mb;type:bigint;default:0" json:"storage_quota_mb"`
StoragePlanName string `gorm:"type:varchar(80);not null;default:''" json:"storage_plan_name"`
StoragePlan string `gorm:"column:storage_plan;type:varchar(64);default:''" json:"storage_plan"`
StoragePlanStartedAt *time.Time `gorm:"column:storage_plan_started_at;type:timestamptz" json:"storage_plan_started_at"`
SmokingCardVisible int64 `gorm:"column:smoking_card_visible;type:bigint;not null;default:1" json:"smoking_card_visible"`
AvatarFileId *string `gorm:"type:uuid;index" json:"avatar_file_id"`
AvatarFileIdRecord *File `gorm:"foreignKey:AvatarFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type Couple struct {
BaseModel
CoupleSpaceId string `gorm:"column:couple_space_id;type:varchar(50);uniqueIndex:ux_couples_couple_space_id,where:deleted_at IS NULL" json:"couple_space_id"`
User1Id string `gorm:"column:user1_id;type:uuid;not null" json:"user1_id"`
User2Id string `gorm:"column:user2_id;type:uuid;not null" json:"user2_id"`
LoveStartDate string `gorm:"column:love_start_date;type:varchar(20);default:''" json:"love_start_date"`
LoveStory string `gorm:"column:love_story;type:text" json:"love_story"`
Status string `gorm:"column:status;type:varchar(20);default:'active'" json:"status"`
DataDeleted int64 `gorm:"column:data_deleted;type:bigint;default:0" json:"data_deleted"`
PeriodVisible int64 `gorm:"column:period_visible;type:bigint;default:0" json:"period_visible"`
PeriodSourceUserId *string `gorm:"column:period_source_user_id;type:uuid" json:"period_source_user_id"`
}
type Invitation struct {
BaseModel
FromUserId string `gorm:"column:from_user_id;type:uuid;not null" json:"from_user_id"`
ToUserId *string `gorm:"column:to_user_id;type:uuid" json:"to_user_id"`
ToPhone string `gorm:"column:to_phone;type:varchar(50);default:''" json:"to_phone"`
Status string `gorm:"column:status;type:varchar(20);default:'pending'" json:"status"`
Message string `gorm:"column:message;type:text" json:"message"`
InviteCode string `gorm:"column:invite_code;type:varchar(100);default:''" json:"invite_code"`
Type string `gorm:"column:type;type:varchar(20);default:'phone'" json:"type"`
RespondedAt *time.Time `gorm:"column:responded_at;type:timestamptz" json:"responded_at"`
}
type UnbindRequest struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
RequesterId string `gorm:"column:requester_id;type:uuid;not null" json:"requester_id"`
Status string `gorm:"column:status;type:varchar(20);default:'pending'" json:"status"`
RespondedAt *time.Time `gorm:"column:responded_at;type:timestamptz" json:"responded_at"`
CancelledAt *time.Time `gorm:"column:cancelled_at;type:timestamptz" json:"cancelled_at"`
CancelledBy *string `gorm:"column:cancelled_by;type:uuid" json:"cancelled_by"`
CoolingUntil *time.Time `gorm:"column:cooling_until;type:timestamptz" json:"cooling_until"`
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"`
}
type InviteLink struct {
BaseModel
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
Code string `gorm:"column:code;type:varchar(100);not null;uniqueIndex:ux_invite_links_code,where:deleted_at IS NULL" json:"code"`
Used int64 `gorm:"column:used;type:bigint;default:0" json:"used"`
}
type Timeline struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"`
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
Content string `gorm:"column:content;type:text" json:"content"`
EventDate string `gorm:"column:event_date;type:varchar(20);not null" json:"event_date"`
Location string `gorm:"column:location;type:varchar(200);default:''" json:"location"`
Images string `gorm:"column:images;type:text" json:"images"`
Icon string `gorm:"column:icon;type:varchar(20);default:'❤️'" json:"icon"`
SortOrder int64 `gorm:"column:sort_order;type:bigint;default:0" json:"sort_order"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
RepeatInterval int64 `gorm:"column:repeat_interval;type:bigint;default:0" json:"repeat_interval"`
RepeatUnit string `gorm:"column:repeat_unit;type:varchar(10);default:''" json:"repeat_unit"`
BgImage string `gorm:"column:bg_image;type:varchar(500);default:''" json:"bg_image"`
BgImageFileId *string `gorm:"type:uuid;index" json:"bg_image_file_id"`
BgImageFileIdRecord *File `gorm:"foreignKey:BgImageFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type Album struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"`
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
Cover string `gorm:"column:cover;type:varchar(500);default:''" json:"cover"`
SortOrder int64 `gorm:"column:sort_order;type:bigint;default:0" json:"sort_order"`
Lat float64 `gorm:"column:lat;type:numeric(10,6);default:0" json:"lat"`
Lng float64 `gorm:"column:lng;type:numeric(10,6);default:0" json:"lng"`
Location string `gorm:"column:location;type:varchar(200);default:''" json:"location"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
CoverFileId *string `gorm:"type:uuid;index" json:"cover_file_id"`
CoverFileIdRecord *File `gorm:"foreignKey:CoverFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type Photo struct {
BaseModel
AlbumId string `gorm:"column:album_id;type:uuid;not null" json:"album_id"`
Path string `gorm:"column:path;type:varchar(500);not null" json:"path"`
Description string `gorm:"column:description;type:text" json:"description"`
TakenAt string `gorm:"column:taken_at;type:varchar(50);default:''" json:"taken_at"`
Location string `gorm:"column:location;type:varchar(200);default:''" json:"location"`
Lat float64 `gorm:"column:lat;type:numeric(10,6);default:0" json:"lat"`
Lng float64 `gorm:"column:lng;type:numeric(10,6);default:0" json:"lng"`
Activity string `gorm:"column:activity;type:varchar(200);default:''" json:"activity"`
BatchId string `gorm:"column:batch_id;type:varchar(50);default:''" json:"batch_id"`
SortOrder int64 `gorm:"column:sort_order;type:bigint;default:0" json:"sort_order"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
FileId *string `gorm:"type:uuid;index" json:"file_id"`
FileIdRecord *File `gorm:"foreignKey:FileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type Diary struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"`
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
Content string `gorm:"column:content;type:text" json:"content"`
Mood string `gorm:"column:mood;type:varchar(20);default:'😊'" json:"mood"`
Weather string `gorm:"column:weather;type:varchar(20);default:'☀️'" json:"weather"`
DiaryDate string `gorm:"column:diary_date;type:varchar(20);not null" json:"diary_date"`
Images string `gorm:"column:images;type:text" json:"images"`
IsAnniversary int64 `gorm:"column:is_anniversary;type:bigint;default:0" json:"is_anniversary"`
Region string `gorm:"column:region;type:varchar(100);default:''" json:"region"`
Lat float64 `gorm:"column:lat;type:numeric(10,6);default:0" json:"lat"`
Lng float64 `gorm:"column:lng;type:numeric(10,6);default:0" json:"lng"`
Location string `gorm:"column:location;type:varchar(200);default:''" json:"location"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
}
type Song struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
SongName string `gorm:"column:song_name;type:varchar(255);not null" json:"song_name"`
Artist string `gorm:"column:artist;type:varchar(255);not null" json:"artist"`
Note string `gorm:"column:note;type:varchar(120);default:''" json:"note"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
}
type Checkin struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
Icon string `gorm:"column:icon;type:varchar(20);default:'✅'" json:"icon"`
Type string `gorm:"column:type;type:varchar(10);default:'dual'" json:"type"`
Frequency string `gorm:"column:frequency;type:varchar(10);default:'daily'" json:"frequency"`
IsCompleted int64 `gorm:"column:is_completed;type:bigint;default:0" json:"is_completed"`
CompletedByMe int64 `gorm:"column:completed_by_me;type:bigint;default:0" json:"completed_by_me"`
CompletedByPartner int64 `gorm:"column:completed_by_partner;type:bigint;default:0" json:"completed_by_partner"`
MyCheckinTime *time.Time `gorm:"column:my_checkin_time;type:timestamptz" json:"my_checkin_time"`
PartnerCheckinTime *time.Time `gorm:"column:partner_checkin_time;type:timestamptz" json:"partner_checkin_time"`
MyStreak int64 `gorm:"column:my_streak;type:bigint;default:0" json:"my_streak"`
PartnerStreak int64 `gorm:"column:partner_streak;type:bigint;default:0" json:"partner_streak"`
MyPhoto string `gorm:"column:my_photo;type:varchar(500);default:''" json:"my_photo"`
PartnerPhoto string `gorm:"column:partner_photo;type:varchar(500);default:''" json:"partner_photo"`
MyNote string `gorm:"column:my_note;type:varchar(200);default:''" json:"my_note"`
PartnerNote string `gorm:"column:partner_note;type:varchar(200);default:''" json:"partner_note"`
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"`
SortOrder int64 `gorm:"column:sort_order;type:bigint;default:0" json:"sort_order"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"created_by"`
MyPhotoFileId *string `gorm:"type:uuid;index" json:"my_photo_file_id"`
MyPhotoFileIdRecord *File `gorm:"foreignKey:MyPhotoFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
PartnerPhotoFileId *string `gorm:"type:uuid;index" json:"partner_photo_file_id"`
PartnerPhotoFileIdRecord *File `gorm:"foreignKey:PartnerPhotoFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type CheckinLog struct {
BaseModel
CheckinId string `gorm:"column:checkin_id;type:uuid;not null" json:"checkin_id"`
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
Photo string `gorm:"column:photo;type:varchar(500);default:''" json:"photo"`
Note string `gorm:"column:note;type:varchar(200);default:''" json:"note"`
PhotoFileId *string `gorm:"type:uuid;index" json:"photo_file_id"`
PhotoFileIdRecord *File `gorm:"foreignKey:PhotoFileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type SmokingAccount struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;uniqueIndex:ix_smoking_accounts_smoking_owner,where:deleted_at IS NULL,priority:1" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ix_smoking_accounts_smoking_owner,where:deleted_at IS NULL,priority:2" json:"user_id"`
Stock int64 `gorm:"column:stock;type:bigint;not null;default:0" json:"stock"`
CheckinId *string `gorm:"column:checkin_id;type:uuid" json:"checkin_id"`
}
type SmokingLog struct {
BaseModel
AccountId string `gorm:"column:account_id;type:uuid;not null;uniqueIndex:ix_smoking_logs_smoking_request,where:deleted_at IS NULL,priority:1;index:ix_smoking_logs_smoking_recent,priority:1" json:"account_id"`
Kind string `gorm:"column:kind;type:varchar(20);not null" json:"kind"`
Quantity int64 `gorm:"column:quantity;type:bigint;not null" json:"quantity"`
Delta int64 `gorm:"column:delta;type:bigint;not null" json:"delta"`
Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"`
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
Note string `gorm:"column:note;type:varchar(200);not null;default:''" json:"note"`
RequestKey string `gorm:"column:request_key;type:varchar(64);not null;uniqueIndex:ix_smoking_logs_smoking_request,where:deleted_at IS NULL,priority:2" json:"request_key"`
PayloadHash string `gorm:"column:payload_hash;type:char(64);not null" json:"payload_hash"`
CheckinLogId *string `gorm:"column:checkin_log_id;type:uuid" json:"checkin_log_id"`
OriginalId *string `gorm:"column:original_id;type:uuid" json:"original_id"`
Undone int64 `gorm:"column:undone;type:bigint;not null;default:0" json:"undone"`
}
type PeriodRecord struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
StartDate string `gorm:"column:start_date;type:date;not null" json:"start_date"`
EndDate string `gorm:"column:end_date;type:date;not null" json:"end_date"`
CycleLength int64 `gorm:"column:cycle_length;type:bigint;default:28" json:"cycle_length"`
PeriodLength int64 `gorm:"column:period_length;type:bigint;default:5" json:"period_length"`
Symptoms string `gorm:"column:symptoms;type:varchar(500);default:''" json:"symptoms"`
Note string `gorm:"column:note;type:text" json:"note"`
}
type PeriodDailyRecord struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid;uniqueIndex:ix_period_daily_records_uk_date_couple,where:deleted_at IS NULL,priority:2" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
RecordDate string `gorm:"column:record_date;type:date;not null;uniqueIndex:ix_period_daily_records_uk_date_couple,where:deleted_at IS NULL,priority:1" json:"record_date"`
Period string `gorm:"column:period;type:varchar(1);default:'0'" json:"period"`
Sex string `gorm:"column:sex;type:varchar(1);default:'0'" json:"sex"`
Symptoms string `gorm:"column:symptoms;type:varchar(200);default:''" json:"symptoms"`
Mood string `gorm:"column:mood;type:varchar(100);default:''" json:"mood"`
Discharge string `gorm:"column:discharge;type:varchar(100);default:''" json:"discharge"`
Temperature *float64 `gorm:"column:temperature;type:numeric(4,1)" json:"temperature"`
Weight *float64 `gorm:"column:weight;type:numeric(5,1)" json:"weight"`
Note string `gorm:"column:note;type:text" json:"note"`
PeriodDetail string `gorm:"column:period_detail;type:text" json:"period_detail"`
}
type PeriodSexRecord struct {
BaseModel
CoupleId *string `gorm:"column:couple_id;type:uuid;index:ix_period_sex_records_idx_date_couple,priority:2" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
RecordDate string `gorm:"column:record_date;type:date;not null;index:ix_period_sex_records_idx_date_couple,priority:1" json:"record_date"`
RecordTime *string `gorm:"column:record_time;type:time without time zone" json:"record_time"`
Measure string `gorm:"column:measure;type:varchar(100);default:''" json:"measure"`
MeasureCustom string `gorm:"column:measure_custom;type:varchar(200);default:''" json:"measure_custom"`
}
type Setting struct {
BaseModel
Key string `gorm:"column:key;type:varchar(255);uniqueIndex:ux_settings_key,where:deleted_at IS NULL" json:"key"`
Value string `gorm:"column:value;type:text" json:"value"`
FileId *string `gorm:"type:uuid;index" json:"file_id"`
FileIdRecord *File `gorm:"foreignKey:FileId;references:Id;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT" json:"-"`
}
type DiaryComment struct {
BaseModel
DiaryId string `gorm:"column:diary_id;type:uuid;not null;index:ix_diary_comments_idx_diary,priority:1" json:"diary_id"`
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
Content string `gorm:"column:content;type:text;not null" json:"content"`
ParentId *string `gorm:"column:parent_id;type:uuid" json:"parent_id"`
ReplyToUserId *string `gorm:"column:reply_to_user_id;type:uuid" json:"reply_to_user_id"`
ReplyToUsername string `gorm:"column:reply_to_username;type:varchar(100);default:''" json:"reply_to_username"`
}
type SubscribeTemplate struct {
BaseModel
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ix_subscribe_templates_uk_user_template,where:deleted_at IS NULL,priority:1" json:"user_id"`
TemplateId string `gorm:"column:template_id;type:varchar(100);not null" json:"template_id"`
TemplateType string `gorm:"column:template_type;type:varchar(50);not null;uniqueIndex:ix_subscribe_templates_uk_user_template,where:deleted_at IS NULL,priority:2" json:"template_type"`
}
type ActivityLog struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;index:ix_activity_log_idx_couple_time,priority:1" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
ActionType string `gorm:"column:action_type;type:varchar(50);not null" json:"action_type"`
Description string `gorm:"column:description;type:varchar(500);not null" json:"description"`
Detail string `gorm:"column:detail;type:text" json:"detail"`
}
type Admin struct {
BaseModel
Username string `gorm:"column:username;type:varchar(100);not null;uniqueIndex:ux_admins_username,where:deleted_at IS NULL" json:"username"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"password"`
}
type UserToken struct {
BaseModel
Token string `gorm:"column:token;type:varchar(128);not null;uniqueIndex:ux_user_tokens_token,where:deleted_at IS NULL;index:ix_user_tokens_idx_token,priority:1" json:"token"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamptz;not null;index:ix_user_tokens_idx_expires,priority:1" json:"expires_at"`
}
type SubscribeMessageLog struct {
BaseModel
ActivityId *string `gorm:"column:activity_id;type:uuid;index:ix_subscribe_message_logs_idx_activity,priority:1" json:"activity_id"`
CoupleId *string `gorm:"column:couple_id;type:uuid;index:ix_subscribe_message_logs_idx_couple,priority:1" json:"couple_id"`
UserId *string `gorm:"column:user_id;type:uuid" json:"user_id"`
ToUserId string `gorm:"column:to_user_id;type:uuid;not null;index:ix_subscribe_message_logs_idx_recipient_time,priority:1" json:"to_user_id"`
ToOpenid string `gorm:"column:to_openid;type:varchar(128);not null;default:'';index:ix_subscribe_message_logs_idx_openid,priority:1" json:"to_openid"`
ActionType string `gorm:"column:action_type;type:varchar(50);not null;default:'';index:ix_subscribe_message_logs_idx_event_time,priority:1" json:"action_type"`
MessageTitle string `gorm:"column:message_title;type:varchar(255);not null;default:''" json:"message_title"`
MessageContent string `gorm:"column:message_content;type:text" json:"message_content"`
TemplateType string `gorm:"column:template_type;type:varchar(50);not null" json:"template_type"`
TemplateId string `gorm:"column:template_id;type:varchar(128);not null;default:''" json:"template_id"`
Page string `gorm:"column:page;type:varchar(255);not null;default:''" json:"page"`
Status string `gorm:"column:status;type:varchar(20);not null;index:ix_subscribe_message_logs_idx_status_time,priority:1" json:"status"`
ErrorCode *int64 `gorm:"column:error_code;type:bigint" json:"error_code"`
HttpStatus *int64 `gorm:"column:http_status;type:bigint" json:"http_status"`
ErrorMsg string `gorm:"column:error_msg;type:text" json:"error_msg"`
Attempts int64 `gorm:"column:attempts;type:bigint;not null;default:0" json:"attempts"`
DurationMs int64 `gorm:"column:duration_ms;type:bigint;not null;default:0" json:"duration_ms"`
SentAt *time.Time `gorm:"column:sent_at;type:timestamptz" json:"sent_at"`
}
type NotifyLog struct {
BaseModel
ActivityId string `gorm:"column:activity_id;type:uuid;not null;uniqueIndex:ix_notify_log_uk_activity,where:deleted_at IS NULL,priority:1" json:"activity_id"`
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
UserId string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
ActionType string `gorm:"column:action_type;type:varchar(50);not null" json:"action_type"`
TemplateType string `gorm:"column:template_type;type:varchar(50);not null" json:"template_type"`
ToUserId string `gorm:"column:to_user_id;type:uuid;not null;uniqueIndex:ix_notify_log_uk_activity,where:deleted_at IS NULL,priority:2" json:"to_user_id"`
Status string `gorm:"column:status;type:varchar(20);not null;default:'';index:ix_notify_log_idx_status_time,priority:1" json:"status"`
ErrorMsg string `gorm:"column:error_msg;type:text" json:"error_msg"`
SentAt *time.Time `gorm:"column:sent_at;type:timestamptz" json:"sent_at"`
}
type Pet struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;uniqueIndex:ux_pets_couple_id,where:deleted_at IS NULL" json:"couple_id"`
Name string `gorm:"column:name;type:varchar(50);not null;default:'小狗狗'" json:"name"`
Breed string `gorm:"column:breed;type:varchar(20);not null;default:'golden'" json:"breed"`
Gender int64 `gorm:"column:gender;type:bigint;not null;default:1" json:"gender"`
Stage int64 `gorm:"column:stage;type:bigint;not null;default:1" json:"stage"`
Level int64 `gorm:"column:level;type:bigint;not null;default:1" json:"level"`
Exp int64 `gorm:"column:exp;type:bigint;not null;default:0" json:"exp"`
Happiness int64 `gorm:"column:happiness;type:bigint;not null;default:100" json:"happiness"`
Hunger int64 `gorm:"column:hunger;type:bigint;not null;default:100" json:"hunger"`
Energy int64 `gorm:"column:energy;type:bigint;not null;default:100" json:"energy"`
Cleanliness int64 `gorm:"column:cleanliness;type:bigint;not null;default:100" json:"cleanliness"`
AdoptedAt string `gorm:"column:adopted_at;type:date;not null" json:"adopted_at"`
CreatedBy string `gorm:"column:created_by;type:uuid;not null" json:"created_by"`
}
type PetDaily struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;uniqueIndex:ix_pet_dailies_uk_couple_date,where:deleted_at IS NULL,priority:1" json:"couple_id"`
Date string `gorm:"column:date;type:date;not null;uniqueIndex:ix_pet_dailies_uk_couple_date,where:deleted_at IS NULL,priority:2" json:"date"`
FeedU1 int64 `gorm:"column:feed_u1;type:bigint;default:0" json:"feed_u1"`
FeedU2 int64 `gorm:"column:feed_u2;type:bigint;default:0" json:"feed_u2"`
WaterU1 int64 `gorm:"column:water_u1;type:bigint;default:0" json:"water_u1"`
WaterU2 int64 `gorm:"column:water_u2;type:bigint;default:0" json:"water_u2"`
WalkU1 int64 `gorm:"column:walk_u1;type:bigint;default:0" json:"walk_u1"`
WalkU2 int64 `gorm:"column:walk_u2;type:bigint;default:0" json:"walk_u2"`
BathU1 int64 `gorm:"column:bath_u1;type:bigint;default:0" json:"bath_u1"`
BathU2 int64 `gorm:"column:bath_u2;type:bigint;default:0" json:"bath_u2"`
SleepU1 int64 `gorm:"column:sleep_u1;type:bigint;default:0" json:"sleep_u1"`
SleepU2 int64 `gorm:"column:sleep_u2;type:bigint;default:0" json:"sleep_u2"`
PlayU1 int64 `gorm:"column:play_u1;type:bigint;default:0" json:"play_u1"`
PlayU2 int64 `gorm:"column:play_u2;type:bigint;default:0" json:"play_u2"`
AllDone int64 `gorm:"column:all_done;type:bigint;default:0" json:"all_done"`
}
type PetAchievement struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;uniqueIndex:ix_pet_achievements_uk_couple_ach,where:deleted_at IS NULL,priority:1" json:"couple_id"`
AchievementKey string `gorm:"column:achievement_key;type:varchar(50);not null;uniqueIndex:ix_pet_achievements_uk_couple_ach,where:deleted_at IS NULL,priority:2" json:"achievement_key"`
UnlockedAt *time.Time `gorm:"column:unlocked_at;type:timestamptz;default:CURRENT_TIMESTAMP" json:"unlocked_at"`
}
type PetItem struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;index:ix_pet_items_idx_couple,priority:1" json:"couple_id"`
ItemKey string `gorm:"column:item_key;type:varchar(50);not null" json:"item_key"`
ItemType string `gorm:"column:item_type;type:varchar(20);not null" json:"item_type"`
Quantity int64 `gorm:"column:quantity;type:bigint;default:1" json:"quantity"`
Equipped int64 `gorm:"column:equipped;type:bigint;default:0" json:"equipped"`
}
type PetDynamic struct {
BaseModel
CoupleId string `gorm:"column:couple_id;type:uuid;not null;index:ix_pet_dynamics_idx_couple_time,priority:1" json:"couple_id"`
UserId *string `gorm:"column:user_id;type:uuid" json:"user_id"`
Action string `gorm:"column:action;type:varchar(30);not null" json:"action"`
Message string `gorm:"column:message;type:text" json:"message"`
}
type StoragePlan struct {
BaseModel
Key string `gorm:"type:varchar(64);not null;uniqueIndex" json:"key"`
Name string `gorm:"type:varchar(80);not null" json:"name"`
QuotaMb int64 `gorm:"not null" json:"quota_mb"` // 0 = unlimited
Months int64 `gorm:"not null" json:"months"`
PriceCents int64 `gorm:"not null" json:"price_cents"`
Enabled bool `gorm:"not null" json:"enabled"`
SortOrder int64 `gorm:"not null;default:0" json:"sort_order"`
}
// Entitlements are immutable purchase snapshots; different capacities expire independently.
type StorageEntitlement struct {
BaseModel
UserId string `gorm:"type:uuid;not null;index" json:"user_id"`
OrderId *string `gorm:"type:uuid;uniqueIndex" json:"order_id"`
SourceKey string `gorm:"type:varchar(100);uniqueIndex;not null" json:"source_key"`
PlanKey string `gorm:"type:varchar(64);not null" json:"plan_key"`
PlanName string `gorm:"type:varchar(80);not null" json:"plan_name"`
QuotaMb int64 `gorm:"not null" json:"quota_mb"`
StartsAt time.Time `gorm:"type:timestamptz;not null" json:"starts_at"`
ExpiresAt time.Time `gorm:"type:timestamptz;not null;index" json:"expires_at"`
}
type StorageOrder struct {
PlanName string `gorm:"type:varchar(80);not null;default:''" json:"plan_name"`
PlanQuotaMb int64 `gorm:"not null;default:0" json:"plan_quota_mb"`
PlanMonths int64 `gorm:"not null;default:0" json:"plan_months"`
BaseModel
PayProvider string `gorm:"type:varchar(20);not null;default:'wechat'" json:"pay_provider"`
PayAppId string `gorm:"type:varchar(64);not null;default:''" json:"pay_app_id"`
PayMchId string `gorm:"type:varchar(32);not null;default:''" json:"pay_mch_id"`
PayerOpenId string `gorm:"type:varchar(128);not null;default:''" json:"payer_open_id"`
PrepayId string `gorm:"type:varchar(128);not null;default:''" json:"prepay_id"`
ExpiresAt *time.Time `gorm:"type:timestamptz" json:"expires_at"`
UserId string `gorm:"column:user_id;type:uuid;not null;index:ix_storage_orders_idx_user,priority:1" json:"user_id"`
CoupleId *string `gorm:"column:couple_id;type:uuid" json:"couple_id"`
OutTradeNo string `gorm:"column:out_trade_no;type:varchar(64);not null;uniqueIndex:ux_storage_orders_out_trade_no,where:deleted_at IS NULL" json:"out_trade_no"`
PlanKey string `gorm:"column:plan_key;type:varchar(64);not null" json:"plan_key"`
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
Status string `gorm:"column:status;type:varchar(20);not null;default:'pending';index:ix_storage_orders_idx_status,priority:1" json:"status"`
WxOrderId string `gorm:"column:wx_order_id;type:varchar(64);not null;default:''" json:"wx_order_id"`
Attach string `gorm:"column:attach;type:text" json:"attach"`
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"`
}
type DiaryLike struct {
BaseModel
DiaryId string `gorm:"column:diary_id;type:uuid;not null;uniqueIndex:ix_diary_likes_diary_user,where:deleted_at IS NULL,priority:1" json:"diary_id"`
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ix_diary_likes_diary_user,where:deleted_at IS NULL,priority:2" json:"user_id"`
}
type DiaryFavorite struct {
BaseModel
DiaryId string `gorm:"column:diary_id;type:uuid;not null;uniqueIndex:ix_diary_favorites_diary_user,where:deleted_at IS NULL,priority:1" json:"diary_id"`
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ix_diary_favorites_diary_user,where:deleted_at IS NULL,priority:2" json:"user_id"`
}
type SmokingWallet struct {
BaseModel
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ux_smoking_wallets_user_id,where:deleted_at IS NULL" json:"user_id"`
Stock int64 `gorm:"column:stock;type:bigint;not null;default:0" json:"stock"`
}
type SmokingEntry struct {
BaseModel
UserId string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:ix_smoking_entries_personal_request,where:deleted_at IS NULL,priority:1;index:ix_smoking_entries_personal_recent,priority:1" json:"user_id"`
Kind string `gorm:"column:kind;type:varchar(20);not null" json:"kind"`
Quantity int64 `gorm:"column:quantity;type:bigint;not null" json:"quantity"`
Delta int64 `gorm:"column:delta;type:bigint;not null" json:"delta"`
Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"`
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
Note string `gorm:"column:note;type:varchar(200);not null;default:''" json:"note"`
RequestKey string `gorm:"column:request_key;type:varchar(64);not null;uniqueIndex:ix_smoking_entries_personal_request,where:deleted_at IS NULL,priority:2" json:"request_key"`
PayloadHash string `gorm:"column:payload_hash;type:char(64);not null" json:"payload_hash"`
OriginalId *string `gorm:"column:original_id;type:uuid" json:"original_id"`
Undone int64 `gorm:"column:undone;type:bigint;not null;default:0" json:"undone"`
}
// Models is the complete AutoMigrate registry.
func Models() []any {
return []any{&FileDeletion{}, &File{}, &FileUpload{}, &RecordFile{}, &UnbindingFileCleanup{}, &User{}, &Couple{}, &Invitation{}, &UnbindRequest{}, &InviteLink{}, &Timeline{}, &Album{}, &Photo{}, &Diary{}, &Song{}, &Checkin{}, &CheckinLog{}, &SmokingAccount{}, &SmokingLog{}, &PeriodRecord{}, &PeriodDailyRecord{}, &PeriodSexRecord{}, &Setting{}, &DiaryComment{}, &SubscribeTemplate{}, &ActivityLog{}, &Admin{}, &UserToken{}, &SubscribeMessageLog{}, &NotifyLog{}, &Pet{}, &PetDaily{}, &PetAchievement{}, &PetItem{}, &PetDynamic{}, &StorageOrder{}, &StoragePlan{}, &StorageEntitlement{}, &DiaryLike{}, &DiaryFavorite{}, &SmokingWallet{}, &SmokingEntry{}}
}
// LegacyTables names logical tables used by existing report queries.
func LegacyTables() map[string]any {
return map[string]any{"files": &File{}, "file_uploads": &FileUpload{}, "record_files": &RecordFile{}, "unbinding_file_cleanup": &UnbindingFileCleanup{}, "users": &User{}, "couples": &Couple{}, "invitations": &Invitation{}, "unbind_requests": &UnbindRequest{}, "invite_links": &InviteLink{}, "timeline": &Timeline{}, "albums": &Album{}, "photos": &Photo{}, "diaries": &Diary{}, "songs": &Song{}, "checkins": &Checkin{}, "checkin_logs": &CheckinLog{}, "smoking_accounts": &SmokingAccount{}, "smoking_logs": &SmokingLog{}, "period_records": &PeriodRecord{}, "period_daily_records": &PeriodDailyRecord{}, "period_sex_records": &PeriodSexRecord{}, "settings": &Setting{}, "diary_comments": &DiaryComment{}, "subscribe_templates": &SubscribeTemplate{}, "activity_log": &ActivityLog{}, "admins": &Admin{}, "user_tokens": &UserToken{}, "subscribe_message_logs": &SubscribeMessageLog{}, "notify_log": &NotifyLog{}, "pets": &Pet{}, "pet_dailies": &PetDaily{}, "pet_achievements": &PetAchievement{}, "pet_items": &PetItem{}, "pet_dynamics": &PetDynamic{}, "storage_orders": &StorageOrder{}, "storage_plans": &StoragePlan{}, "storage_entitlements": &StorageEntitlement{}, "diary_likes": &DiaryLike{}, "diary_favorites": &DiaryFavorite{}, "smoking_wallets": &SmokingWallet{}, "smoking_entries": &SmokingEntry{}}
}
+43
View File
@@ -0,0 +1,43 @@
package model
import (
"gorm.io/gorm/schema"
"sync"
"testing"
)
func TestFileForeignKeysTargetPrimaryIDs(t *testing.T) {
cache := &sync.Map{}
n := schema.NamingStrategy{TablePrefix: "past_", SingularTable: true}
owner, err := schema.Parse(&FileUpload{}, cache, n)
if err != nil {
t.Fatal(err)
}
relation := owner.Relationships.Relations["User"]
if relation.Type != schema.BelongsTo || len(relation.References) != 1 || relation.References[0].ForeignKey.Schema.Name != "FileUpload" || relation.References[0].PrimaryKey.Name != "Id" {
t.Fatal("upload ownership must reference users.id, never its display number")
}
user, err := schema.Parse(&User{}, cache, n)
if err != nil {
t.Fatal(err)
}
if user.LookUpField("user_id").DataType != schema.DataType("varchar(20)") {
t.Fatal("file association changed the public display number type")
}
for _, m := range []any{&RecordFile{}, &User{}, &Photo{}, &Album{}, &Checkin{}, &CheckinLog{}, &Setting{}, &Timeline{}} {
s, e := schema.Parse(m, cache, n)
if e != nil {
t.Fatal(e)
}
for _, r := range s.Relationships.Relations {
if r.Type != schema.BelongsTo {
t.Fatalf("%s.%s inferred wrong relationship %s", s.Name, r.Name, r.Type)
}
for _, ref := range r.References {
if ref.PrimaryKey.Name != "Id" {
t.Fatal("file reference does not target a primary UUID")
}
}
}
}
}
+16 -12
View File
@@ -3,7 +3,7 @@
package notify package notify
import ( import (
"log/slog" "go.uber.org/zap"
"strings" "strings"
"time" "time"
@@ -54,7 +54,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
slog.Error("cron_notify panic", "err", r) zap.S().Errorw("cron_notify panic", "err", r)
} }
}() }()
Once(db, wx, false) Once(db, wx, false)
@@ -64,7 +64,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
} }
type Sender interface { type Sender interface {
SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool SendSubscribeMessage(toUserID string, templateType string, data map[string]string, page string, activityID string, userID string) bool
} }
// force 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。 // force 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。
@@ -74,11 +74,11 @@ func Once(db *store.DB, wx Sender, force bool) {
for _, action := range actionTypes { for _, action := range actionTypes {
args = append(args, action) args = append(args, action)
} }
query := `SELECT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a query := `SELECT DISTINCT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a
JOIN users u ON u.id=a.user_id JOIN users u ON u.id=a.user_id
JOIN couples cp ON cp.id=a.couple_id AND cp.status='active' AND u.couple_id=cp.id JOIN couples cp ON cp.id=a.couple_id AND cp.status='active' AND u.couple_id=cp.id
JOIN users recipient ON recipient.id=CASE WHEN cp.user1_id=u.id THEN cp.user2_id ELSE cp.user1_id END AND recipient.couple_id=cp.id JOIN users recipient ON recipient.id=CASE WHEN cp.user1_id=u.id THEN cp.user2_id ELSE cp.user1_id END AND recipient.couple_id=cp.id
JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type='notify' JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type IN ('notify','checkin')
WHERE a.action_type IN (` + ph + `) AND u.id IN (cp.user1_id, cp.user2_id) WHERE a.action_type IN (` + ph + `) AND u.id IN (cp.user1_id, cp.user2_id)
AND NOT EXISTS (SELECT 1 FROM notify_log nl WHERE nl.activity_id=a.id AND nl.to_user_id=recipient.id AND nl.status='success') AND NOT EXISTS (SELECT 1 FROM notify_log nl WHERE nl.activity_id=a.id AND nl.to_user_id=recipient.id AND nl.status='success')
AND NOT EXISTS (SELECT 1 FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id AND sl.status='success') AND NOT EXISTS (SELECT 1 FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id AND sl.status='success')
@@ -91,21 +91,21 @@ func Once(db *store.DB, wx Sender, force bool) {
args = append(args, maxBatch) args = append(args, maxBatch)
activities, err := db.Query(query, args...) activities, err := db.Query(query, args...)
if err != nil { if err != nil {
slog.Error("cron_notify 查询失败", "err", err) zap.S().Errorw("cron_notify 查询失败", "err", err)
return return
} }
processed, success, fail, skip := 0, 0, 0, 0 processed, success, fail, skip := 0, 0, 0, 0
for _, a := range activities { for _, a := range activities {
activityID, userID, actionType := a.Int("id"), a.Int("user_id"), a.Str("action_type") activityID, userID, actionType := a.Str("id"), a.Str("user_id"), a.Str("action_type")
nickname := a.Str("nickname") nickname := a.Str("nickname")
if nickname == "" { if nickname == "" {
nickname = "你的TA" nickname = "你的TA"
} }
partnerID := a.Int("to_user_id") partnerID := a.Str("to_user_id")
// 扫描后共享开关或关系可能改变;实际发送前再次检查。 // 扫描后共享开关或关系可能改变;实际发送前再次检查。
allowed, err := db.QueryOne(`SELECT u.period_visible FROM users u JOIN couples cp ON cp.id=u.couple_id AND cp.status='active' allowed, err := db.QueryOne(`SELECT u.period_visible FROM users u JOIN couples cp ON cp.id=u.couple_id AND cp.status='active'
JOIN users recipient ON recipient.id=? AND recipient.couple_id=cp.id JOIN users recipient ON recipient.id=? AND recipient.couple_id=cp.id
WHERE u.id=? AND cp.id=? AND u.id IN (cp.user1_id,cp.user2_id) AND recipient.id IN (cp.user1_id,cp.user2_id) AND recipient.id<>u.id`, partnerID, userID, a.Int("couple_id")) WHERE u.id=? AND cp.id=? AND u.id IN (cp.user1_id,cp.user2_id) AND recipient.id IN (cp.user1_id,cp.user2_id) AND recipient.id<>u.id`, partnerID, userID, a.Str("couple_id"))
if err != nil || allowed == nil || ((actionType == "period_add" || actionType == "period_sex_add") && allowed.Int("period_visible") == 0) { if err != nil || allowed == nil || ((actionType == "period_add" || actionType == "period_sex_add") && allowed.Int("period_visible") == 0) {
skip++ skip++
continue continue
@@ -136,9 +136,13 @@ func Once(db *store.DB, wx Sender, force bool) {
if thing2 == "" { if thing2 == "" {
thing2 = thing1 thing2 = thing1
} }
data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"} data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕", "event_time": a.Str("created_at")}
ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID) templateType := "notify"
if actionType == "checkin_do" {
data = wechat.CheckinMessage(desc, a.Str("detail"), a.Str("created_at"))
}
ok = wx.SendSubscribeMessage(partnerID, templateType, data, page, activityID, userID)
processed++ processed++
if ok { if ok {
success++ success++
@@ -146,5 +150,5 @@ func Once(db *store.DB, wx Sender, force bool) {
fail++ fail++
} }
} }
slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip) zap.S().Infow("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip)
} }
+27 -6
View File
@@ -10,12 +10,15 @@ import (
type testSender struct { type testSender struct {
calls int calls int
to, from int64 to, from string
typ string
data map[string]string
} }
func (s *testSender) SendSubscribeMessage(to int64, typ string, data map[string]string, page string, activity, from int64) bool { func (s *testSender) SendSubscribeMessage(to string, typ string, data map[string]string, page string, activity, from string) bool {
s.calls++ s.calls++
s.to, s.from = to, from s.to, s.from = to, from
s.typ, s.data = typ, data
return true return true
} }
@@ -35,7 +38,7 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) {
{"force still checks sharing", 0, 0, true, true, 0}, {"force still checks sharing", 0, 0, true, true, 0},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
steps := []testdb.Step{{Match: "SELECT a.*", Check: func(query string, _ []any) error { steps := []testdb.Step{{Match: "SELECT DISTINCT a.*", Check: func(query string, _ []any) error {
for _, part := range []string{"nl.status='success'", "sl.status='success'", "cp.status='active'", "u.couple_id=cp.id", "COALESCE(u.period_visible,0)<>0", "JOIN subscribe_templates"} { for _, part := range []string{"nl.status='success'", "sl.status='success'", "cp.status='active'", "u.couple_id=cp.id", "COALESCE(u.period_visible,0)<>0", "JOIN subscribe_templates"} {
if !strings.Contains(query, part) { if !strings.Contains(query, part) {
t.Errorf("missing guard %s", part) t.Errorf("missing guard %s", part)
@@ -56,13 +59,31 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) {
} }
db, _ := testdb.Open(t, steps) db, _ := testdb.Open(t, steps)
sender := &testSender{} sender := &testSender{}
Once(&store.DB{DB: db}, sender, tt.force) Once(store.WrapSQL(db), sender, tt.force)
if sender.calls != tt.want { if sender.calls != tt.want {
t.Fatalf("sent %d want %d", sender.calls, tt.want) t.Fatalf("sent %d want %d", sender.calls, tt.want)
} }
if sender.calls > 0 && (sender.to != 2 || sender.from != 1) { if sender.calls > 0 && (sender.to != "2" || sender.from != "1") {
t.Fatalf("message must go to partner: from=%d to=%d", sender.from, sender.to) t.Fatalf("message must go to partner: from=%s to=%s", sender.from, sender.to)
} }
}) })
} }
} }
func TestCheckinRetryUsesDedicatedTemplate(t *testing.T) {
db, _ := testdb.Open(t, []testdb.Step{
{Match: "SELECT DISTINCT a.*", Check: func(q string, _ []any) error {
if !strings.Contains(q, "st.template_type IN ('notify','checkin')") {
t.Error("retry selected wrong authorization")
}
return nil
}, Columns: []string{"id", "couple_id", "user_id", "to_user_id", "action_type", "created_at", "description", "detail"}, Rows: [][]driver.Value{{int64(9), int64(5), int64(1), int64(2), "checkin_do", "2026-11-03 20:58:00", "打卡「运动」", "今天也坚持啦"}}},
{Match: "SELECT u.period_visible", Columns: []string{"period_visible"}, Rows: [][]driver.Value{{int64(1)}}},
{Match: "SELECT COUNT(*) as cnt FROM subscribe_message_logs", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(1)}}},
})
sender := &testSender{}
Once(store.WrapSQL(db), sender, false)
if sender.calls != 1 || sender.typ != "notify" || len(sender.data) != 3 || sender.data["thing1"] != "今天也坚持啦" || sender.data["time2"] != "2026年11月03日 20:58" {
t.Fatalf("wrong retry: %+v", sender)
}
}
+225
View File
@@ -0,0 +1,225 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/model"
"strings"
)
var ErrAccountBound = errors.New("请先解除情侣关系,再注销账号")
var ErrAccountChanged = errors.New("数据已发生变化,请重新确认注销统计")
type AccountSummary struct {
Counts map[string]int64 `json:"counts"`
FileBytes int64 `json:"file_bytes"`
RecordCounts map[string]int64 `json:"record_counts"`
Revision string `json:"revision"`
}
func accountUnbound(tx *gorm.DB, uid string, lock bool) error {
var u model.User
q := tx
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := q.First(&u, "id=?", uid).Error; err != nil {
return err
}
if u.CoupleId != nil && *u.CoupleId != "" {
return ErrAccountBound
}
var n int64
if err := tx.Model(&model.Couple{}).Where("(user1_id=? OR user2_id=?) AND status <> 'unbound'", uid, uid).Count(&n).Error; err != nil {
return err
}
if n > 0 {
return ErrAccountBound
}
return nil
}
func summaryIn(tx *gorm.DB, uid string) (AccountSummary, error) {
result := AccountSummary{Counts: map[string]int64{}, RecordCounts: map[string]int64{}}
for name, q := range map[string]*gorm.DB{
"albums": tx.Unscoped().Model(&model.Album{}).Where("created_by=?", uid),
"photos": tx.Unscoped().Model(&model.Photo{}).Where("created_by=? OR album_id IN (?)", uid, tx.Unscoped().Model(&model.Album{}).Select("id").Where("created_by=?", uid)),
"diaries": tx.Unscoped().Model(&model.Diary{}).Where("created_by=?", uid),
"timeline": tx.Unscoped().Model(&model.Timeline{}).Where("created_by=?", uid),
"checkins": tx.Unscoped().Model(&model.CheckinLog{}).Where("user_id=?", uid),
"smoking": tx.Unscoped().Model(&model.SmokingEntry{}).Where("user_id=?", uid),
"period": tx.Unscoped().Model(&model.PeriodDailyRecord{}).Where("user_id=?", uid),
"files": tx.Unscoped().Model(&model.FileUpload{}).Where("user_id=?", uid).Distinct("file_id"),
} {
var count int64
if err := q.Count(&count).Error; err != nil {
return result, err
}
result.Counts[name] = count
}
if err := tx.Unscoped().Model(&model.File{}).Where("id IN (?)", tx.Unscoped().Model(&model.FileUpload{}).Select("file_id").Where("user_id=?", uid)).Select("COALESCE(SUM(size),0)").Scan(&result.FileBytes).Error; err != nil {
return result, err
}
// Revision covers actual row data, including edits that leave counts unchanged.
hash := sha256.New()
for _, m := range model.Models() {
st := &gorm.Statement{DB: tx}
if err := st.Parse(m); err != nil {
return result, err
}
if st.Schema.Table == "past_user" || st.Schema.Table == "past_file_deletion" {
continue
}
filters := []string{}
args := []any{}
for _, f := range st.Schema.Fields {
switch f.DBName {
case "user_id", "created_by", "from_user_id", "to_user_id", "requester_id":
filters = append(filters, `"`+f.DBName+`"=?`)
args = append(args, uid)
}
}
if len(filters) == 0 {
continue
}
var digest struct {
Count int64
Digest string
}
query := `SELECT COUNT(*) AS count,md5(COALESCE(string_agg(md5(row_to_json(t)::text),'' ORDER BY id),'')) AS digest FROM "` + st.Schema.Table + `" t WHERE ` + strings.Join(filters, " OR ")
if err := tx.Raw(query, args...).Scan(&digest).Error; err != nil {
return result, err
}
result.RecordCounts[st.Schema.Table] = digest.Count
hash.Write([]byte(st.Schema.Table))
hash.Write([]byte(digest.Digest))
}
result.Revision = hex.EncodeToString(hash.Sum(nil))
return result, nil
}
func (db *DB) AccountDeletionSummary(uid string) (AccountSummary, error) {
var result AccountSummary
err := db.ORM.Transaction(func(tx *gorm.DB) error {
if err := accountUnbound(tx, uid, true); err != nil {
return err
}
var err error
result, err = summaryIn(tx, uid)
return err
})
return result, err
}
func (db *DB) DeleteAccount(uid, revision string) error {
return db.ORM.Transaction(func(tx *gorm.DB) error {
if err := accountUnbound(tx, uid, true); err != nil {
return err
}
summary, err := summaryIn(tx, uid)
if err != nil {
return err
}
if summary.Revision != revision {
return ErrAccountChanged
}
var fileIds []string
if err := tx.Unscoped().Model(&model.FileUpload{}).Where("user_id=?", uid).Distinct("file_id").Pluck("file_id", &fileIds).Error; err != nil {
return err
}
// Remove dependent records before their parents, including previously soft-deleted data.
statements := []string{
`DELETE FROM past_setting WHERE key IN (CONCAT('bg_user_',?),CONCAT('bgver_user_',?))`,
`UPDATE past_diary_comment SET reply_to_user_id=NULL,reply_to_username='' WHERE reply_to_user_id=?`,
`DELETE FROM past_record_file WHERE (owner_table='diaries' AND owner_id IN (SELECT id FROM past_diary WHERE created_by=?)) OR (owner_table='timeline' AND owner_id IN (SELECT id FROM past_timeline WHERE created_by=?))`,
`DELETE FROM past_diary_comment WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`,
`DELETE FROM past_diary_like WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`,
`DELETE FROM past_diary_favorite WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`,
`DELETE FROM past_photo WHERE album_id IN (SELECT id FROM past_album WHERE created_by=?)`,
`DELETE FROM past_checkin_log WHERE checkin_id IN (SELECT id FROM past_checkin WHERE created_by=?)`,
`DELETE FROM past_smoking_log WHERE account_id IN (SELECT id FROM past_smoking_account WHERE user_id=?)`,
}
for _, sql := range statements {
args := []any{}
for i := 0; i < strings.Count(sql, "?"); i++ {
args = append(args, uid)
}
if err := tx.Exec(sql, args...).Error; err != nil {
return err
}
}
for _, m := range model.Models() {
st := &gorm.Statement{DB: tx}
if err := st.Parse(m); err != nil {
return err
}
if st.Schema.Table == "past_user" || st.Schema.Table == "past_file_deletion" {
continue
}
filters := []string{}
args := []any{}
for _, f := range st.Schema.Fields {
switch f.DBName {
case "user_id", "created_by", "from_user_id", "to_user_id", "requester_id":
filters = append(filters, `"`+f.DBName+`"=?`)
args = append(args, uid)
}
}
if len(filters) > 0 {
if err := tx.Exec(`DELETE FROM "`+st.Schema.Table+`" WHERE `+strings.Join(filters, " OR "), args...).Error; err != nil {
return err
}
}
}
if err := tx.Unscoped().Delete(&model.User{}, "id=?", uid).Error; err != nil {
return err
}
for _, id := range fileIds {
var file model.File
if err := tx.Unscoped().First(&file, "id=?", id).Error; err != nil {
return err
}
// Serialize with MD5 dedup uploads. Shared objects remain for other users.
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", file.Bucket+":"+file.Md5).Error; err != nil {
return err
}
referenced := false
for _, m := range model.Models() {
st := &gorm.Statement{DB: tx}
if err := st.Parse(m); err != nil {
return err
}
if st.Schema.Table == "past_file_deletion" {
continue
}
filters := []string{}
args := []any{}
for _, f := range st.Schema.Fields {
if f.DBName == "file_id" || strings.HasSuffix(f.DBName, "_file_id") {
filters = append(filters, `"`+f.DBName+`"=?`)
args = append(args, id)
}
}
if len(filters) > 0 {
var n int64
if err := tx.Table(st.Schema.Table).Where(strings.Join(filters, " OR "), args...).Count(&n).Error; err != nil {
return err
}
if n > 0 {
referenced = true
break
}
}
}
if !referenced {
if err := tx.Create(&model.FileDeletion{FileId: id}).Error; err != nil {
return err
}
if err := tx.Delete(&file).Error; err != nil {
return err
}
}
}
return nil
})
}
+163 -164
View File
@@ -1,113 +1,161 @@
// Package store 封装 MySQL 访问。查询结果统一返回 Row(map),并按列类型转换成与 PHP PDO(mysqlnd) // Package store provides GORM repositories and scoped PostgreSQL report queries.
// 一致的 JSON 表现:整数列 → 数字,DECIMAL/聚合 SUM/AVG → 字符串,日期时间 → 字符串,NULL → null。
package store package store
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
_ "github.com/go-sql-driver/mysql" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
) )
var NamingStrategy = schema.NamingStrategy{TablePrefix: "past_", SingularTable: true}
func ormConfig() *gorm.Config {
return &gorm.Config{NamingStrategy: NamingStrategy, Logger: logger.Default.LogMode(logger.Silent)}
}
type DB struct { type DB struct {
*sql.DB ORM *gorm.DB
// NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为:
// 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。
NumericAsString bool NumericAsString bool
UploadDir string // 用于解绑完成后的持久化附件清理队列
} }
// Row 一行查询结果,键为列名(或别名)。
type Row map[string]any type Row map[string]any
// Open connects to an existing PostgreSQL database; it never creates or migrates it.
func Open(dsn string) (*DB, error) { func Open(dsn string) (*DB, error) {
db, err := sql.Open("mysql", dsn) orm, err := gorm.Open(postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true}), ormConfig())
if err != nil {
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
pool, err := orm.DB()
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetMaxOpenConns(20) pool.SetMaxOpenConns(20)
db.SetMaxIdleConns(5) pool.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour) pool.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := db.PingContext(ctx); err != nil { if err := pool.PingContext(ctx); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err) pool.Close()
return nil, err
} }
return &DB{DB: db}, nil return &DB{ORM: orm}, nil
} }
// Querier 由 *DB 与 *Tx 共同实现,便于处理函数在事务内外复用。 // EnsureDatabase creates only the configured database, using the maintenance database.
type Querier interface { // Database identifiers are quoted by pgx, never interpolated as SQL fragments.
Query(query string, args ...any) ([]Row, error) func EnsureDatabase(dsn string) error {
QueryOne(query string, args ...any) (Row, error) cfg, err := pgx.ParseConfig(dsn)
Exec(query string, args ...any) (sql.Result, error)
Insert(query string, args ...any) (int64, error)
}
type sqlRunner interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func (d *DB) Query(query string, args ...any) ([]Row, error) {
return queryRows(d.DB, d.NumericAsString, query, args...)
}
// QueryOne 返回第一行;无结果时返回 nil, nil(对应 PDO fetch() 返回 false)。
func (d *DB) QueryOne(query string, args ...any) (Row, error) {
return queryOne(d.DB, d.NumericAsString, query, args...)
}
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.DB.ExecContext(context.Background(), query, args...)
}
// Insert 执行 INSERT 并返回自增 ID。
func (d *DB) Insert(query string, args ...any) (int64, error) {
res, err := d.Exec(query, args...)
if err != nil { if err != nil {
return 0, err return err
} }
return res.LastInsertId() name := cfg.Database
if name == "" {
return fmt.Errorf("数据库名不能为空")
}
cfg.Database = "postgres"
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, cfg)
if err != nil {
return fmt.Errorf("连接 PostgreSQL 管理库失败: %w", err)
}
defer conn.Close(context.Background())
var exists bool
if err := conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)", name).Scan(&exists); err != nil {
return err
}
if exists {
return nil
}
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize()+" ENCODING 'UTF8' TEMPLATE template0")
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "42P04" {
return nil
}
return err
}
// WrapSQL is also used by deterministic transaction tests; all operations still use GORM.
func WrapSQL(pool *sql.DB) *DB {
cfg := ormConfig()
cfg.DisableAutomaticPing = true
cfg.SkipDefaultTransaction = true
orm, err := gorm.Open(postgres.New(postgres.Config{Conn: pool, PreferSimpleProtocol: true}), cfg)
if err != nil {
panic(err)
}
return &DB{ORM: orm}
}
func (d *DB) Close() error {
pool, err := d.ORM.DB()
if err != nil {
return err
}
return pool.Close()
}
type Querier interface {
Query(string, ...any) ([]Row, error)
QueryOne(string, ...any) (Row, error)
Exec(string, ...any) (sql.Result, error)
Insert(string, ...any) (string, error)
}
func (d *DB) Query(q string, args ...any) ([]Row, error) {
return queryRows(d.ORM, d.NumericAsString, q, args...)
}
func (d *DB) QueryOne(q string, args ...any) (Row, error) {
return queryOne(d.ORM, d.NumericAsString, q, args...)
}
func (d *DB) Exec(q string, args ...any) (sql.Result, error) { return execute(d.ORM, q, args...) }
func (d *DB) Insert(q string, args ...any) (string, error) {
r, e := d.Exec(q, args...)
if e != nil {
return "", e
}
return r.(writeResult).id, nil
} }
// Tx 事务
type Tx struct { type Tx struct {
*sql.Tx ORM *gorm.DB
numStr bool numStr bool
} }
func (d *DB) Begin() (*Tx, error) { func (d *DB) Begin() (*Tx, error) {
tx, err := d.DB.Begin() t := d.ORM.Begin()
if err != nil { return &Tx{ORM: t, numStr: d.NumericAsString}, t.Error
return nil, err }
func (t *Tx) Commit() error { return t.ORM.Commit().Error }
func (t *Tx) Rollback() error { return t.ORM.Rollback().Error }
func (t *Tx) Query(q string, args ...any) ([]Row, error) {
return queryRows(t.ORM, t.numStr, q, args...)
}
func (t *Tx) QueryOne(q string, args ...any) (Row, error) {
return queryOne(t.ORM, t.numStr, q, args...)
}
func (t *Tx) Exec(q string, args ...any) (sql.Result, error) { return execute(t.ORM, q, args...) }
func (t *Tx) Insert(q string, args ...any) (string, error) {
r, e := t.Exec(q, args...)
if e != nil {
return "", e
} }
return &Tx{tx, d.NumericAsString}, nil return r.(writeResult).id, nil
} }
func (t *Tx) Query(query string, args ...any) ([]Row, error) { func queryOne(db *gorm.DB, numStr bool, q string, args ...any) (Row, error) {
return queryRows(t.Tx, t.numStr, query, args...) rows, err := queryRows(db, numStr, q, args...)
}
func (t *Tx) QueryOne(query string, args ...any) (Row, error) {
return queryOne(t.Tx, t.numStr, query, args...)
}
func (t *Tx) Exec(query string, args ...any) (sql.Result, error) {
return t.Tx.ExecContext(context.Background(), query, args...)
}
func (t *Tx) Insert(query string, args ...any) (int64, error) {
res, err := t.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error) {
rows, err := queryRows(r, numStr, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -117,8 +165,12 @@ func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error)
return rows[0], nil return rows[0], nil
} }
func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, error) { func queryRows(db *gorm.DB, numStr bool, q string, args ...any) ([]Row, error) {
rs, err := r.QueryContext(context.Background(), query, args...) scoped, err := scopeQuery(q)
if err != nil {
return nil, err
}
rs, err := db.Raw(scoped, args...).Rows()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -131,73 +183,56 @@ func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
kinds := make([]colKind, len(cols))
for i, t := range types {
kinds[i] = kindOf(t.DatabaseTypeName())
if numStr {
kinds[i] = kindString
}
}
out := make([]Row, 0, 8) out := make([]Row, 0, 8)
raw := make([]sql.RawBytes, len(cols))
ptrs := make([]any, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
for rs.Next() { for rs.Next() {
values := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range values {
ptrs[i] = &values[i]
}
if err := rs.Scan(ptrs...); err != nil { if err := rs.Scan(ptrs...); err != nil {
return nil, err return nil, err
} }
row := make(Row, len(cols)) row := make(Row, len(cols))
for i, c := range cols { for i, c := range cols {
row[c] = convert(kinds[i], raw[i]) value := values[i]
if b, ok := value.([]byte); ok {
value = string(b)
}
if tm, ok := value.(time.Time); ok {
switch strings.ToUpper(types[i].DatabaseTypeName()) {
case "DATE":
value = tm.Format("2006-01-02")
case "TIME", "TIMETZ":
value = tm.Format("15:04:05")
default:
loc, _ := time.LoadLocation("Asia/Shanghai")
value = tm.In(loc).Format("2006-01-02 15:04:05")
}
}
if numStr && value != nil {
value = ToStr(value)
}
row[c] = value
} }
out = append(out, row) out = append(out, row)
} }
return out, rs.Err() if err := rs.Err(); err != nil {
return nil, err
}
// Release the connection before the batch file lookup (also works with a one-connection pool).
if err := rs.Close(); err != nil {
return nil, err
}
if err := hydrateMediaRows(db, out); err != nil {
return nil, err
}
return out, nil
} }
type colKind int // ExecNoFK preserves the legacy callback API. The new models deliberately model
// actor references as scalar UUIDs (system activities use a NULL user_id).
const ( func (d *DB) ExecNoFK(fn func(Querier) error) error { return fn(d) }
kindString colKind = iota
kindInt
kindFloat
)
func kindOf(dbType string) colKind {
t := strings.ToUpper(dbType)
t = strings.TrimPrefix(t, "UNSIGNED ")
switch t {
case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "YEAR", "BIT":
return kindInt
case "FLOAT", "DOUBLE":
return kindFloat
default:
// DECIMAL / VARCHAR / TEXT / DATETIME / DATE / JSON ... 在 PDO 下均为字符串
return kindString
}
}
func convert(k colKind, b sql.RawBytes) any {
if b == nil {
return nil
}
switch k {
case kindInt:
if n, err := strconv.ParseInt(string(b), 10, 64); err == nil {
return n
}
return string(b)
case kindFloat:
if f, err := strconv.ParseFloat(string(b), 64); err == nil {
return f
}
return string(b)
default:
return string(b)
}
}
// ---- Row 取值辅助(对应 PHP 弱类型读取)---- // ---- Row 取值辅助(对应 PHP 弱类型读取)----
@@ -337,39 +372,3 @@ func atoiPrefix(s string) int64 {
} }
return n return n
} }
// ExecNoFK 在一条独占连接上临时关闭外键检查执行 fn(用于 PHP 代码里向 pet_dynamics 写 user_id=0 的场景)。
func (d *DB) ExecNoFK(fn func(q Querier) error) error {
conn, err := d.DB.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 1")
return fn(&connQuerier{conn, d.NumericAsString})
}
type connQuerier struct {
*sql.Conn
numStr bool
}
func (c *connQuerier) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (c *connQuerier) Insert(query string, args ...any) (int64, error) {
res, err := c.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
+345
View File
@@ -0,0 +1,345 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/model"
)
var ErrUploadQuota = errors.New("存储空间不足,请扩容后重试")
type actorKey struct{}
type mediaActor struct {
UserId string
Admin bool
}
func (d *DB) WithActor(userId string, admin bool) *DB {
copy := *d
copy.ORM = d.ORM.WithContext(context.WithValue(d.ORM.Statement.Context, actorKey{}, mediaActor{userId, admin}))
return &copy
}
func FileAccessPath(id string) string { return "/api/files/" + id }
func FileIdentifier(value string) string {
if ValidUUID(value) {
return value
}
u, err := url.Parse(value)
if err == nil {
path := strings.TrimPrefix(u.Path, "/api/files/")
if path != u.Path && ValidUUID(path) {
return path
}
}
return ""
}
func fileForValue(db *gorm.DB, value string) (model.File, error) {
var file model.File
id := FileIdentifier(value)
var err error
if id != "" {
err = db.First(&file, "id=?", id).Error
} else {
u, e := url.Parse(value)
if e != nil || u.Scheme == "" {
return file, fmt.Errorf("文件必须先上传并使用文件 ID 关联")
}
u.RawQuery = ""
err = db.First(&file, "url=?", u.String()).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return file, fmt.Errorf("文件不存在或已删除")
}
if err != nil {
return file, err
}
actor, scoped := db.Statement.Context.Value(actorKey{}).(mediaActor)
if !scoped || actor.Admin {
return file, nil
}
var user model.User
if actor.UserId == "" {
return file, fmt.Errorf("没有文件关联权限")
}
if err := db.First(&user, "id=?", actor.UserId).Error; err != nil {
return file, err
}
owners := []string{actor.UserId}
if user.CoupleId != nil {
if err := db.Model(&model.User{}).Where("couple_id=?", *user.CoupleId).Pluck("id", &owners).Error; err != nil {
return file, err
}
}
var count int64
if err := db.Model(&model.FileUpload{}).Where("file_id=? AND user_id IN ?", file.Id, owners).Count(&count).Error; err != nil {
return file, err
}
if count == 0 {
return file, fmt.Errorf("不能关联其他用户的文件")
}
return file, nil
}
var singleMedia = map[string]map[string]string{
"users": {"avatar": "avatar_file_id"}, "albums": {"cover": "cover_file_id"}, "photos": {"path": "file_id"},
"timeline": {"bg_image": "bg_image_file_id"}, "checkins": {"my_photo": "my_photo_file_id", "partner_photo": "partner_photo_file_id"},
"checkin_logs": {"photo": "photo_file_id"}, "settings": {"value": "file_id"},
}
type mediaPlan map[string][]string
func plainMedia(value any) (any, bool) {
if expr, ok := value.(clause.Expr); ok {
switch strings.TrimSpace(expr.SQL) {
case "''", "NULL":
return "", true
default:
return nil, false
}
}
return value, true
}
// Existing path-shaped API fields become UUID mirrors; canonical *_file_id
// columns have real foreign keys. No object URL/path is persisted in business rows.
func prepareMedia(db *gorm.DB, table string, values map[string]any) (mediaPlan, error) {
plan := mediaPlan{}
if db.DryRun {
return plan, nil
}
for field, column := range singleMedia[table] {
value, present := values[field]
if !present {
value, present = values[column]
}
if !present {
continue
}
raw, ok := plainMedia(value)
if !ok {
continue
}
text := ToStr(raw)
if table == "settings" && FileIdentifier(text) == "" {
values[column] = nil
continue
}
if text == "" || text == "0" {
values[field] = ""
values[column] = nil
continue
}
file, err := fileForValue(db, text)
if err != nil {
return nil, err
}
values[field] = file.Id
values[column] = file.Id
}
if table == "diaries" || table == "timeline" {
if value, ok := values["images"]; ok {
raw, literal := plainMedia(value)
if !literal {
return nil, fmt.Errorf("图片关联不能使用 SQL 表达式")
}
var list []any
switch v := raw.(type) {
case nil:
list = []any{}
case []any:
list = v
case []string:
for _, s := range v {
list = append(list, s)
}
case string:
if v != "" {
if err := json.Unmarshal([]byte(v), &list); err != nil {
return nil, fmt.Errorf("图片列表格式不正确")
}
}
default:
return nil, fmt.Errorf("图片列表格式不正确")
}
ids := make([]string, 0, len(list))
for _, item := range list {
file, err := fileForValue(db, ToStr(item))
if err != nil {
return nil, err
}
ids = append(ids, file.Id)
}
data, _ := json.Marshal(ids)
values["images"] = string(data)
plan["images"] = ids
}
}
return plan, nil
}
func saveMediaPlan(db *gorm.DB, table, id string, plan mediaPlan) error {
if db.DryRun {
return nil
}
for field, ids := range plan {
if err := db.Where("owner_table=? AND owner_id=? AND field=?", table, id, field).Delete(&model.RecordFile{}).Error; err != nil {
return err
}
for position, fileId := range ids {
ref := model.RecordFile{OwnerTable: table, OwnerId: id, Field: field, Position: position, FileId: fileId}
if err := db.Create(&ref).Error; err != nil {
return err
}
}
}
return nil
}
// hydrateMediaRows resolves ID mirrors in one batched lookup and keeps public
// response fields compatible while also exposing canonical file IDs.
func hydrateMediaRows(db *gorm.DB, rows []Row) error {
fields := map[string]bool{"avatar": true, "cover": true, "path": true, "photo": true, "my_photo": true, "partner_photo": true, "bg_image": true, "latest_photo": true, "value": true}
ids := map[string]bool{}
for _, row := range rows {
for key, value := range row {
if fields[key] || strings.HasSuffix(key, "_avatar") || key == "media" {
if id := FileIdentifier(ToStr(value)); id != "" {
ids[id] = true
}
}
if key == "images" {
var list []string
if json.Unmarshal([]byte(ToStr(value)), &list) == nil {
for _, id := range list {
if ValidUUID(id) {
ids[id] = true
}
}
}
}
}
}
if len(ids) == 0 {
return nil
}
keys := make([]string, 0, len(ids))
for id := range ids {
keys = append(keys, id)
}
var files []model.File
if err := db.Where("id IN ?", keys).Find(&files).Error; err != nil {
return err
}
active := map[string]bool{}
for _, f := range files {
active[f.Id] = true
}
for _, row := range rows {
for key, value := range row {
if fields[key] || strings.HasSuffix(key, "_avatar") || key == "media" {
if id := FileIdentifier(ToStr(value)); id != "" {
if active[id] {
row[key] = FileAccessPath(id)
} else {
row[key] = ""
}
}
}
if key == "images" {
var list []string
if json.Unmarshal([]byte(ToStr(value)), &list) == nil {
paths := make([]string, 0, len(list))
fileIds := make([]string, 0, len(list))
for _, id := range list {
if ValidUUID(id) {
if active[id] {
paths = append(paths, FileAccessPath(id))
fileIds = append(fileIds, id)
}
} else {
paths = append(paths, id)
}
}
data, _ := json.Marshal(paths)
row[key] = string(data)
row["image_file_ids"] = fileIds
}
}
}
}
return nil
}
// UploadOwners and UsedFileBytes count a shared object once within a user's space.
func UploadOwners(db *gorm.DB, userId string) ([]string, error) {
ids := []string{userId}
if userId == "" {
return nil, nil
}
var user model.User
if err := db.First(&user, "id=?", userId).Error; err != nil {
return nil, err
}
if user.CoupleId != nil {
if err := db.Model(&model.User{}).Where("couple_id=?", *user.CoupleId).Pluck("id", &ids).Error; err != nil {
return nil, err
}
}
return ids, nil
}
func UsedFileBytes(db *gorm.DB, owners []string) (int64, error) {
var total int64
query := db.Model(&model.File{})
if owners != nil {
query = query.Where("id IN (?)", db.Model(&model.FileUpload{}).Select("file_id").Where("user_id IN ?", owners))
}
err := query.Select("COALESCE(SUM(size),0)").Scan(&total).Error
return total, err
}
func CheckFileQuota(db *gorm.DB, userId string, file model.File) error {
if userId == "" {
return nil
}
owners, err := UploadOwners(db, userId)
if err != nil {
return err
}
var existing int64
if file.Id != "" {
if err := db.Model(&model.FileUpload{}).Where("user_id IN ? AND file_id=?", owners, file.Id).Count(&existing).Error; err != nil {
return err
}
if existing > 0 && !file.DeletedAt.Valid {
return nil
}
}
allowance, err := StorageSpace(db, userId)
if err != nil {
return err
}
if allowance.Unlimited {
return nil
}
limitMB := allowance.QuotaMb
used, err := UsedFileBytes(db, owners)
if err != nil {
return err
}
if used+file.Size > limitMB*1024*1024 {
return ErrUploadQuota
}
return nil
}
func GrantFile(db *gorm.DB, userId, fileId string) error {
if userId == "" {
return nil
}
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.FileUpload{UserId: userId, FileId: fileId}).Error
}
@@ -0,0 +1,289 @@
package store
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"gorm.io/gorm"
"past/internal/model"
)
// Opt in with PAST_TEST_POSTGRES_DSN. All test data is rolled back.
func TestPostgresIntegration(t *testing.T) {
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set PAST_TEST_POSTGRES_DSN for real PostgreSQL verification")
}
db, err := Open(dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var oldTableCount, newTableCount int64
if err := db.ORM.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND table_name LIKE 'past-%'").Scan(&oldTableCount).Error; err != nil {
t.Fatal(err)
}
if err := db.ORM.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND left(table_name,5)='past_'").Scan(&newTableCount).Error; err != nil {
t.Fatal(err)
}
if oldTableCount != 0 || newTableCount != int64(len(model.Models())) {
t.Fatalf("table prefix migration incomplete: old=%d new=%d", oldTableCount, newTableCount)
}
t.Logf("verified %d past_ tables and no past- tables", newTableCount)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
exec := func(q string, a ...any) {
t.Helper()
if _, e := tx.Exec(q, a...); e != nil {
t.Fatalf("%s: %v", q, e)
}
}
insert := func(q string, a ...any) string {
t.Helper()
id, e := tx.Insert(q, a...)
if e != nil {
t.Fatalf("%s: %v", q, e)
}
var uuid pgtype.UUID
if e = uuid.Scan(id); e != nil || len(id) != 36 {
t.Fatalf("invalid UUID %q: %v", id, e)
}
return id
}
user := insert("INSERT INTO users (user_id,username,nickname) VALUES (?,?,?)", "i"+time.Now().Format("150405.000000000"), "i"+time.Now().Format("150405.000000000"), "test")
second := insert("INSERT INTO users (user_id,username) VALUES (?,?)", "s"+user[:8], "second-"+user)
couple := insert("INSERT INTO couples (user1_id,user2_id,couple_space_id) VALUES (?,?,?)", user, second, "CP"+strings.ReplaceAll(user, "-", ""))
exec("UPDATE users SET couple_id = ? WHERE id IN (?,?)", couple, user, second)
album := insert("INSERT INTO albums (couple_id,title,created_by) VALUES (?,?,?)", couple, "test", user)
file := model.File{Size: 1, Suffix: "png", ContentType: "image/png", ObjectKey: "integration/" + user + ".png", Md5: strings.ReplaceAll(user, "-", ""), Sha256: strings.Repeat("a", 64), Url: "https://example.test/" + user, Bucket: "integration"}
if err := tx.ORM.Create(&file).Error; err != nil {
t.Fatal(err)
}
photo := insert("INSERT INTO photos (album_id,path,created_by) VALUES (?,?,?)", album, file.Id, user)
row, e := tx.QueryOne("SELECT p.*,a.title,u.nickname FROM photos p LEFT JOIN albums a ON a.id=p.album_id LEFT JOIN users u ON u.id=p.created_by WHERE p.id=?", photo)
if e != nil || row == nil || row.Str("nickname") != "test" {
t.Fatalf("join: %v %v", row, e)
}
if _, e := tx.QueryOne("SELECT id FROM couples WHERE id=? FOR UPDATE", couple); e != nil {
t.Fatal(e)
}
exec("UPDATE users SET storage_vip_expire_at = (COALESCE(storage_vip_expire_at,NOW()) + (? * INTERVAL '1 month')) WHERE id=?", 1, user)
exec("INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value=EXCLUDED.value", "integration-"+user, "one")
exec("INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value=EXCLUDED.value", "integration-"+user, "two")
row, e = tx.QueryOne("SELECT value FROM settings WHERE key=?", "integration-"+user)
if e != nil || row.Str("value") != "two" {
t.Fatalf("upsert: %v %v", row, e)
}
exec("DELETE FROM settings WHERE key=?", "integration-"+user)
exec("INSERT INTO settings (key,value) VALUES (?,?)", "integration-"+user, "three")
exec("INSERT INTO smoking_wallets (user_id) VALUES (?) ON CONFLICT DO NOTHING", user)
exec("INSERT INTO smoking_wallets (user_id) VALUES (?) ON CONFLICT DO NOTHING", user)
insert("INSERT INTO smoking_entries (user_id,kind,quantity,delta,balance,request_key,payload_hash) VALUES (?,?,?,?,?,?,?)", user, "purchase", 20, 20, 20, "test", strings.Repeat("a", 64))
if _, e := tx.Query("SELECT TO_CHAR(created_at,'YYYY-MM-DD') AS day,COUNT(*) AS count FROM smoking_entries WHERE user_id=? GROUP BY TO_CHAR(created_at,'YYYY-MM-DD')", user); e != nil {
t.Fatal(e)
}
exec("INSERT INTO period_daily_records (couple_id,user_id,record_date,period) VALUES (?,?,?,'1') ON CONFLICT (record_date,couple_id) WHERE deleted_at IS NULL DO UPDATE SET period=EXCLUDED.period", couple, user, "2026-11-01")
exec("INSERT INTO period_daily_records (couple_id,user_id,record_date,period) VALUES (?,?,?,'1') ON CONFLICT (record_date,couple_id) WHERE deleted_at IS NULL DO UPDATE SET period=EXCLUDED.period", couple, user, "2026-11-01")
exec("DELETE FROM photos WHERE id=?", photo)
row, e = tx.QueryOne("SELECT COUNT(*) AS count FROM photos WHERE id=?", photo)
if e != nil || row.Int("count") != 0 {
t.Fatalf("soft-delete filter: %v %v", row, e)
}
var retained model.Photo
if e := tx.ORM.Unscoped().First(&retained, "id=?", photo).Error; e != nil || !retained.DeletedAt.Valid {
t.Fatalf("physical row not retained: %+v %v", retained, e)
}
exec("UPDATE photos SET description=? WHERE id=?", "should not change", photo)
if e := tx.ORM.Unscoped().First(&retained, "id=?", photo).Error; e != nil || retained.Description == "should not change" {
t.Fatal("updated a deleted record", e)
}
// Check that a deleted right-hand join target stays absent without hiding its parent.
exec("DELETE FROM users WHERE id=?", second)
row, e = tx.QueryOne("SELECT c.id,u.nickname FROM couples c LEFT JOIN users u ON u.id=c.user2_id WHERE c.id=?", couple)
if e != nil || row == nil || !row.IsNull("nickname") {
t.Fatalf("LEFT JOIN deleted row: %v %v", row, e)
}
// All registered physical tables have the complete UUID BaseModel schema.
for _, m := range model.Models() {
st := &gorm.Statement{DB: tx.ORM}
if e := st.Parse(m); e != nil {
t.Fatal(e)
}
var col struct {
DataType string
ColumnDefault *string
}
if e := tx.ORM.Raw("SELECT data_type,column_default FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=? AND column_name='id'", st.Schema.Table).Scan(&col).Error; e != nil || col.DataType != "uuid" || col.ColumnDefault == nil || !strings.Contains(*col.ColumnDefault, "gen_random_uuid") {
t.Fatalf("%s invalid UUID identity: %+v %v", st.Schema.Table, col, e)
}
for _, field := range []string{"created_at", "updated_at", "deleted_at"} {
if !tx.ORM.Migrator().HasColumn(m, field) {
t.Fatalf("%s missing %s", st.Schema.Table, field)
}
}
}
}
// Validate the complete static read-query surface against PostgreSQL's planner.
// EXPLAIN (without ANALYZE) does not execute SELECT functions or modify data.
func TestPostgresReportQueries(t *testing.T) {
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set PAST_TEST_POSTGRES_DSN")
}
db, err := Open(dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
checked := 0
for _, dir := range []string{"../handler", "../httpapi", "../wechat", "../notify", "."} {
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
path := filepath.Join(dir, entry.Name())
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatal(err)
}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || (sel.Sel.Name != "Query" && sel.Sel.Name != "QueryOne") {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
q, err := strconv.Unquote(lit.Value)
if err != nil || !strings.HasPrefix(strings.TrimSpace(q), "SELECT") {
return true
}
scoped, err := scopeQuery(q)
if err != nil {
t.Errorf("%s: %v", fset.Position(call.Pos()), err)
return true
}
ts, _ := tokens(q)
var args []any
for _, tok := range ts {
if tok.text == "?" {
args = append(args, nil)
}
}
rows, err := db.ORM.Raw("EXPLAIN "+scoped, args...).Rows()
if err != nil {
t.Errorf("%s: %v\n%s", fset.Position(call.Pos()), err, q)
} else {
rows.Close()
checked++
}
return true
})
}
}
t.Logf("PostgreSQL planned %d static report queries", checked)
}
func TestPostgresWriteStatements(t *testing.T) {
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set PAST_TEST_POSTGRES_DSN")
}
db, err := Open(dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var sql string
capture := func(tx *gorm.DB) { sql = tx.Dialector.Explain(tx.Statement.SQL.String(), tx.Statement.Vars...) }
db.ORM.Callback().Create().After("gorm:create").Register("test:capture", capture)
db.ORM.Callback().Update().After("gorm:update").Register("test:capture", capture)
db.ORM.Callback().Delete().After("gorm:delete").Register("test:capture", capture)
dry := db.ORM.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true})
checked := 0
for _, dir := range []string{"../handler", "../httpapi", "../wechat", "."} {
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
path := filepath.Join(dir, entry.Name())
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatal(err)
}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || (sel.Sel.Name != "Exec" && sel.Sel.Name != "Insert") {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
q, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
if !strings.HasPrefix(q, "INSERT ") && !strings.HasPrefix(q, "UPDATE ") && !strings.HasPrefix(q, "DELETE ") {
return true
}
ts, _ := tokens(q)
var args []any
for _, tok := range ts {
if tok.text == "?" {
args = append(args, nil)
}
}
sql = ""
if _, err := execute(dry, q, args...); err != nil {
t.Errorf("%s: %v", fset.Position(call.Pos()), err)
return true
}
if sql == "" {
t.Errorf("%s: no GORM statement generated", fset.Position(call.Pos()))
return true
}
rows, err := db.ORM.Raw("EXPLAIN " + sql).Rows()
if err != nil {
t.Errorf("%s: %v\n%s", fset.Position(call.Pos()), err, sql)
} else {
rows.Close()
checked++
}
return true
})
}
}
t.Logf("GORM/PostgreSQL planned %d static write statements without executing them", checked)
}
+16 -21
View File
@@ -3,7 +3,7 @@ package store
import ( import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"log/slog" "go.uber.org/zap"
"strings" "strings"
"time" "time"
) )
@@ -16,13 +16,13 @@ func (db *DB) AdvanceUnbindings() error {
return err return err
} }
for _, row := range pending { for _, row := range pending {
if err := db.finishUnbinding(row.Int("couple_id")); err != nil { if err := db.finishUnbinding(row.Str("couple_id")); err != nil {
return err return err
} }
} }
return nil return nil
} }
func (db *DB) finishUnbinding(cid int64) error { func (db *DB) finishUnbinding(cid string) error {
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return err
@@ -36,9 +36,6 @@ func (db *DB) finishUnbinding(cid int64) error {
if err != nil || req == nil { if err != nil || req == nil {
return err return err
} }
if err := queueUnboundFiles(tx, cid, cp.Str("couple_space_id")); err != nil {
return err
}
for _, stmt := range sharedDataDeleteStatements { for _, stmt := range sharedDataDeleteStatements {
args := []any{cid} args := []any{cid}
if strings.Contains(stmt, "bgver_") { if strings.Contains(stmt, "bgver_") {
@@ -62,11 +59,9 @@ func (db *DB) finishUnbinding(cid int64) error {
func (db *DB) RunUnbindingWorker() { func (db *DB) RunUnbindingWorker() {
run := func() { run := func() {
if err := db.AdvanceUnbindings(); err != nil { if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑到期处理失败", "err", err) zap.S().Errorw("解绑到期处理失败", "err", err)
}
if err := db.cleanUnboundFiles(); err != nil {
slog.Error("解绑附件清理失败,将重试", "err", err)
} }
} }
run() run()
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
@@ -83,22 +78,22 @@ func (db *DB) MigrateSmokingRecords() error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO settings (`key`,`value`) VALUES ('smoking_records_v1','pending')"); err != nil { if _, err = tx.Exec("INSERT INTO settings (key,value) VALUES ('smoking_records_v1','pending') ON CONFLICT DO NOTHING"); err != nil {
return err return err
} }
marker, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='smoking_records_v1' FOR UPDATE") marker, err := tx.QueryOne("SELECT value FROM settings WHERE key='smoking_records_v1' FOR UPDATE")
if err != nil { if err != nil {
return err return err
} }
if marker.Str("value") == "done" { if marker.Str("value") == "done" {
return tx.Commit() return tx.Commit()
} }
previous, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='account_records_v1'") previous, err := tx.QueryOne("SELECT value FROM settings WHERE key='account_records_v1'")
if err != nil { if err != nil {
return err return err
} }
if previous != nil && previous.Str("value") == "done" { if previous != nil && previous.Str("value") == "done" {
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil { if _, err = tx.Exec("UPDATE settings SET value='done' WHERE key='smoking_records_v1'"); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@@ -112,27 +107,27 @@ func (db *DB) MigrateSmokingRecords() error {
return err return err
} }
} }
logs, err := tx.Query("SELECT l.*,a.user_id FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id ORDER BY l.id ASC") logs, err := tx.Query("SELECT l.*,a.user_id FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id ORDER BY l.created_at ASC, l.id ASC")
if err != nil { if err != nil {
return err return err
} }
latestKeys := map[string]int64{} latestKeys := map[string]string{}
for _, row := range logs { for _, row := range logs {
latestKeys[fmt.Sprint(row.Int("user_id"), ":", row.Str("request_key"))] = row.Int("id") latestKeys[fmt.Sprint(row.Str("user_id"), ":", row.Str("request_key"))] = row.Str("id")
} }
balances := map[int64]int64{} balances := map[string]int64{}
for _, row := range logs { for _, row := range logs {
uid := row.Int("user_id") uid := row.Str("user_id")
balances[uid] += row.Int("delta") balances[uid] += row.Int("delta")
key := row.Str("request_key") key := row.Str("request_key")
if latestKeys[fmt.Sprint(uid, ":", key)] != row.Int("id") { if latestKeys[fmt.Sprint(uid, ":", key)] != row.Str("id") {
key = fmt.Sprintf("legacy-%x", sha256.Sum256([]byte(fmt.Sprint(row["id"], ":", key))))[:64] key = fmt.Sprintf("legacy-%x", sha256.Sum256([]byte(fmt.Sprint(row["id"], ":", key))))[:64]
} }
if _, err = tx.Exec("INSERT INTO smoking_entries (id,user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id,undone,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", row["id"], uid, row["kind"], row["quantity"], row["delta"], balances[uid], row["amount_cents"], row["note"], key, row["payload_hash"], row["original_id"], row["undone"], row["created_at"]); err != nil { if _, err = tx.Exec("INSERT INTO smoking_entries (id,user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id,undone,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", row["id"], uid, row["kind"], row["quantity"], row["delta"], balances[uid], row["amount_cents"], row["note"], key, row["payload_hash"], row["original_id"], row["undone"], row["created_at"]); err != nil {
return err return err
} }
} }
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil { if _, err = tx.Exec("UPDATE settings SET value='done' WHERE key='smoking_records_v1'"); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
+17 -28
View File
@@ -22,7 +22,6 @@ func TestCoolingCompletionRechecksUnderLock(t *testing.T) {
{Match: "cooling_until <= NOW() FOR UPDATE", Columns: []string{"id"}, Rows: reqRows}, {Match: "cooling_until <= NOW() FOR UPDATE", Columns: []string{"id"}, Rows: reqRows},
} }
if !cancelled { if !cancelled {
steps = append(steps, cleanupQuerySteps()...)
for _, stmt := range sharedDataDeleteStatements { for _, stmt := range sharedDataDeleteStatements {
args := []any{int64(7)} args := []any{int64(7)}
if strings.Contains(stmt, "bgver_") { if strings.Contains(stmt, "bgver_") {
@@ -33,7 +32,7 @@ func TestCoolingCompletionRechecksUnderLock(t *testing.T) {
steps = append(steps, testdb.Step{Match: "UPDATE users SET couple_id = NULL"}, testdb.Step{Match: "data_deleted = 1"}, testdb.Step{Match: "status = 'completed'"}) steps = append(steps, testdb.Step{Match: "UPDATE users SET couple_id = NULL"}, testdb.Step{Match: "data_deleted = 1"}, testdb.Step{Match: "status = 'completed'"})
} }
sqlDB, s := testdb.Open(t, steps) sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB} db := WrapSQL(sqlDB)
if err := db.AdvanceUnbindings(); err != nil { if err := db.AdvanceUnbindings(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -52,10 +51,9 @@ func TestCoolingFailureRollsBackRelationship(t *testing.T) {
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}}, {Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}}, {Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
} }
steps = append(steps, cleanupQuerySteps()...)
steps = append(steps, testdb.Step{Match: sharedDataDeleteStatements[0], Err: errors.New("database unavailable")}) steps = append(steps, testdb.Step{Match: sharedDataDeleteStatements[0], Err: errors.New("database unavailable")})
sqlDB, s := testdb.Open(t, steps) sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB} db := WrapSQL(sqlDB)
if err := db.AdvanceUnbindings(); err == nil { if err := db.AdvanceUnbindings(); err == nil {
t.Fatal("failure ignored") t.Fatal("failure ignored")
} }
@@ -64,8 +62,8 @@ func TestCoolingFailureRollsBackRelationship(t *testing.T) {
} }
} }
func TestSmokingMigrationDoesNotRepeat(t *testing.T) { func TestSmokingMigrationDoesNotRepeat(t *testing.T) {
sqlDB, s := testdb.Open(t, []testdb.Step{{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}}) sqlDB, s := testdb.Open(t, []testdb.Step{{Match: "INSERT INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}})
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil { if err := (WrapSQL(sqlDB)).MigrateSmokingRecords(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if s.Commits != 1 { if s.Commits != 1 {
@@ -75,8 +73,10 @@ func TestSmokingMigrationDoesNotRepeat(t *testing.T) {
func TestSmokingMigrationPreservesMergedInventory(t *testing.T) { func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
checkEntry := func(id string, balance int64, key string) func(string, []any) error { checkEntry := func(id string, balance int64, key string) func(string, []any) error {
return func(_ string, a []any) error { return func(q string, a []any) error {
if a[0] != id || a[1] != int64(1) || a[5] != balance { v := testdb.InsertArgs(q, a)
a = []any{v["id"], v["user_id"], v["kind"], v["quantity"], v["delta"], v["balance"], v["amount_cents"], v["note"], v["request_key"]}
if a[0] != id || a[1] != "1" || a[5] != balance {
return fmt.Errorf("lost ledger id/owner/balance: %v", a) return fmt.Errorf("lost ledger id/owner/balance: %v", a)
} }
if key != "" && a[8] != key { if key != "" && a[8] != key {
@@ -90,19 +90,19 @@ func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
} }
columns := []string{"id", "user_id", "kind", "quantity", "delta", "amount_cents", "note", "request_key", "payload_hash", "original_id", "undone", "created_at"} columns := []string{"id", "user_id", "kind", "quantity", "delta", "amount_cents", "note", "request_key", "payload_hash", "original_id", "undone", "created_at"}
sqlDB, s := testdb.Open(t, []testdb.Step{ sqlDB, s := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}}, {Match: "INSERT INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}}, {Match: "WHERE key='account_records_v1'", Columns: []string{"value"}},
{Match: "SUM(stock)", Columns: []string{"user_id", "stock"}, Rows: [][]driver.Value{{int64(1), int64(19)}}}, {Match: "SUM(stock)", Columns: []string{"user_id", "stock"}, Rows: [][]driver.Value{{int64(1), int64(19)}}},
{Match: "INSERT INTO smoking_wallets", Args: []any{"1", "19"}}, {Match: "INSERT INTO smoking_wallets", Args: []any{int64(19), "1"}},
{Match: "FROM smoking_logs", Columns: columns, Rows: [][]driver.Value{ {Match: "FROM smoking_logs", Columns: columns, Rows: [][]driver.Value{
{int64(10), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-01 00:00:00"}, {int64(10), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-01 00:00:00"},
{int64(11), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-02 00:00:00"}, {int64(11), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-02 00:00:00"},
{int64(12), int64(1), "consume", int64(1), int64(-1), int64(0), "", "consume-key", "hash", nil, int64(0), "2026-10-03 00:00:00"}, {int64(12), int64(1), "consume", int64(1), int64(-1), int64(0), "", "consume-key", "hash", nil, int64(0), "2026-10-03 00:00:00"},
}}, }},
{Match: "INSERT INTO smoking_entries", Check: checkEntry("10", 10, "")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("11", 20, "same-key")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("12", 19, "consume-key")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("10", 10, "")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("11", 20, "same-key")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("12", 19, "consume-key")},
{Match: "SET `value`='done'"}, {Match: "SET value='done'"},
}) })
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil { if err := (WrapSQL(sqlDB)).MigrateSmokingRecords(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if s.Commits != 1 { if s.Commits != 1 {
@@ -112,26 +112,15 @@ func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) { func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) {
db, script := testdb.Open(t, []testdb.Step{ db, script := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"}, {Match: "INSERT INTO settings"},
{Match: "smoking_records_v1' FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}}, {Match: "smoking_records_v1' FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}, {Match: "WHERE key='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}},
{Match: "UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"}, {Match: "UPDATE settings SET value='done' WHERE key='smoking_records_v1'"},
}) })
if err := (&DB{DB: db}).MigrateSmokingRecords(); err != nil { if err := (WrapSQL(db)).MigrateSmokingRecords(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if script.Commits != 1 { if script.Commits != 1 {
t.Fatal("previous smoking migration was not acknowledged") t.Fatal("previous smoking migration was not acknowledged")
} }
} }
func cleanupQuerySteps() []testdb.Step {
return []testdb.Step{
{Match: "SELECT path AS media", Columns: []string{"media"}},
{Match: "SELECT cover AS media", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM diaries", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM timeline", Columns: []string{"media"}},
{Match: "SELECT bg_image AS media", Columns: []string{"media"}},
{Match: "SELECT `value` AS media", Columns: []string{"media"}},
}
}
+413
View File
@@ -0,0 +1,413 @@
package store
import (
"fmt"
"reflect"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/model"
)
var tables = model.LegacyTables()
// Report SQL keeps logical names as aliases. Every table reference, including
// nested subqueries and either side of a LEFT JOIN, is scoped before execution.
// CRUD below always uses typed GORM models and their soft-delete callbacks.
type sqlToken struct {
text string
start, end int
word bool
}
func tokens(s string) ([]sqlToken, error) {
var out []sqlToken
for i := 0; i < len(s); {
if s[i] == ' ' || s[i] == '\n' || s[i] == '\t' || s[i] == '\r' {
i++
continue
}
start := i
if s[i] == '\'' || s[i] == '"' {
quote := s[i]
i++
closed := false
for i < len(s) {
if s[i] == quote {
i++
if i < len(s) && s[i] == quote {
i++
continue
}
closed = true
break
}
i++
}
if !closed {
return nil, fmt.Errorf("unterminated SQL literal")
}
out = append(out, sqlToken{s[start:i], start, i, false})
continue
}
word := (s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || s[i] == '_'
i++
if word {
for i < len(s) && ((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= '0' && s[i] <= '9') || s[i] == '_') {
i++
}
}
out = append(out, sqlToken{s[start:i], start, i, word})
}
return out, nil
}
// GORM callbacks write timestamps/deleted flags into models. Never share a
// mutable model instance across requests, transactions or goroutines.
func freshModel(logical string) (any, bool) {
prototype, ok := tables[logical]
if !ok {
return nil, false
}
return reflect.New(reflect.TypeOf(prototype).Elem()).Interface(), true
}
func physicalTable(logical string) (string, error) {
m, ok := tables[logical]
if !ok {
return "", fmt.Errorf("unknown model table %q", logical)
}
if tabler, ok := m.(interface{ TableName() string }); ok {
return tabler.TableName(), nil
}
return NamingStrategy.TableName(reflect.TypeOf(m).Elem().Name()), nil
}
func scopeQuery(q string) (string, error) {
ts, err := tokens(q)
if err != nil {
return "", err
}
var b strings.Builder
last := 0
keywords := " WHERE ON LEFT RIGHT INNER OUTER FULL CROSS JOIN GROUP ORDER LIMIT OFFSET UNION FOR HAVING SET RETURNING USING "
for i, t := range ts {
if !strings.EqualFold(t.text, "FROM") && !strings.EqualFold(t.text, "JOIN") {
continue
}
if i+1 >= len(ts) || !ts[i+1].word {
continue
}
if i > 0 && strings.EqualFold(ts[i-1].text, "DISTINCT") {
continue
}
table := ts[i+1]
name, err := physicalTable(table.text)
if err != nil {
return "", err
}
alias := ""
if i+2 >= len(ts) || !ts[i+2].word || strings.Contains(keywords, " "+strings.ToUpper(ts[i+2].text)+" ") {
alias = " AS " + table.text
}
b.WriteString(q[last:table.start])
b.WriteString(`(SELECT * FROM "` + name + `" WHERE deleted_at IS NULL)` + alias)
last = table.end
}
b.WriteString(q[last:])
return b.String(), nil
}
// splitSQL separates top-level comma lists without splitting functions or literals.
func splitSQL(s string) ([]string, error) {
ts, err := tokens(s)
if err != nil {
return nil, err
}
var parts []string
depth, last := 0, 0
for _, t := range ts {
switch t.text {
case "(":
depth++
case ")":
depth--
case ",":
if depth == 0 {
parts = append(parts, strings.TrimSpace(s[last:t.start]))
last = t.end
}
}
}
if depth != 0 {
return nil, fmt.Errorf("unbalanced SQL expression")
}
parts = append(parts, strings.TrimSpace(s[last:]))
return parts, nil
}
func bindExpr(s string, args []any, pos *int) (any, error) {
ts, err := tokens(s)
if err != nil {
return nil, err
}
n := 0
for _, t := range ts {
if t.text == "?" {
n++
}
}
if *pos+n > len(args) {
return nil, fmt.Errorf("SQL argument count mismatch")
}
bound := args[*pos : *pos+n]
*pos += n
if strings.TrimSpace(s) == "?" {
return bound[0], nil
}
scoped, err := scopeQuery(s)
if err != nil {
return nil, err
}
return gorm.Expr(scoped, bound...), nil
}
func assignments(s string, args []any, pos *int) (map[string]any, error) {
parts, err := splitSQL(s)
if err != nil {
return nil, err
}
values := map[string]any{}
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
return nil, fmt.Errorf("invalid assignment %q", part)
}
key := strings.TrimSpace(kv[0])
if !identifier.MatchString(key) {
return nil, fmt.Errorf("invalid column %q", key)
}
value, err := bindExpr(strings.TrimSpace(kv[1]), args, pos)
if err != nil {
return nil, err
}
values[key] = value
}
return values, nil
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
var insertPattern = regexp.MustCompile(`(?is)^INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s+VALUES\s*\(`)
var updatePattern = regexp.MustCompile(`(?is)^UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$`)
var deletePattern = regexp.MustCompile(`(?is)^DELETE\s+FROM\s+(\w+)\s+WHERE\s+(.+)$`)
var conflictPattern = regexp.MustCompile(`(?is)^ON CONFLICT\s*\(([^)]+)\)\s+WHERE deleted_at IS NULL\s+DO UPDATE SET\s+(.+)$`)
type writeResult struct {
id string
affected int64
}
func (r writeResult) LastInsertId() (int64, error) {
return 0, fmt.Errorf("UUID 主键使用 Insert 返回的字符串,不能调用 LastInsertId")
}
func (r writeResult) RowsAffected() (int64, error) { return r.affected, nil }
func execute(db *gorm.DB, q string, args ...any) (writeResult, error) {
if db.DryRun || db.SkipDefaultTransaction {
return executeSQL(db, q, args...)
}
if _, ok := db.Statement.ConnPool.(gorm.TxCommitter); ok {
return executeSQL(db, q, args...)
}
var result writeResult
err := db.Transaction(func(tx *gorm.DB) error { var err error; result, err = executeSQL(tx, q, args...); return err })
return result, err
}
func executeSQL(db *gorm.DB, q string, args ...any) (writeResult, error) {
if actor, ok := db.Statement.Context.Value(actorKey{}).(mediaActor); ok && !actor.Admin && actor.UserId != "" && !db.DryRun {
var user model.User
if err := db.Select("id").Clauses(clause.Locking{Strength: "KEY SHARE"}).First(&user, "id=?", actor.UserId).Error; err != nil {
return writeResult{}, fmt.Errorf("账号不存在或已注销")
}
}
q = strings.TrimSpace(q)
pos := 0
if m := deletePattern.FindStringSubmatch(q); m != nil {
target, ok := freshModel(m[1])
if !ok {
return writeResult{}, fmt.Errorf("unknown delete model %q", m[1])
}
where, err := scopeQuery(m[2])
if err != nil {
return writeResult{}, err
}
tx := db.Where(where, args...).Delete(target)
return writeResult{affected: tx.RowsAffected}, tx.Error
}
if m := updatePattern.FindStringSubmatch(q); m != nil {
target, ok := freshModel(m[1])
if !ok {
return writeResult{}, fmt.Errorf("unknown update model %q", m[1])
}
values, err := assignments(m[2], args, &pos)
if err != nil {
return writeResult{}, err
}
if _, ok := values["updated_at"]; !ok {
values["updated_at"] = time.Now()
}
where, err := scopeQuery(m[3])
if err != nil {
return writeResult{}, err
}
plan, err := prepareMedia(db, m[1], values)
if err != nil {
return writeResult{}, err
}
var owners []struct{ Id string }
if len(plan) > 0 && !db.DryRun {
if err := db.Model(target).Select("id").Where(where, args[pos:]...).Clauses(clause.Locking{Strength: "UPDATE"}).Find(&owners).Error; err != nil {
return writeResult{}, err
}
}
tx := db.Model(target).Where(where, args[pos:]...).Updates(values)
if tx.Error != nil {
return writeResult{}, tx.Error
}
for _, owner := range owners {
if err := saveMediaPlan(db, m[1], owner.Id, plan); err != nil {
return writeResult{}, err
}
}
return writeResult{affected: tx.RowsAffected}, nil
}
if loc := insertPattern.FindStringSubmatchIndex(q); loc != nil {
logical := q[loc[2]:loc[3]]
target, ok := freshModel(logical)
if !ok {
return writeResult{}, fmt.Errorf("unknown insert model %q", logical)
}
cols := strings.Split(q[loc[4]:loc[5]], ",")
start := loc[1]
depth := 1
end := -1
ts, err := tokens(q[start:])
if err != nil {
return writeResult{}, err
}
for _, t := range ts {
if t.text == "(" {
depth++
}
if t.text == ")" {
depth--
if depth == 0 {
end = start + t.start
break
}
}
}
if end < 0 {
return writeResult{}, fmt.Errorf("invalid INSERT VALUES")
}
exprs, err := splitSQL(q[start:end])
if err != nil {
return writeResult{}, err
}
if len(cols) != len(exprs) {
return writeResult{}, fmt.Errorf("INSERT column/value mismatch")
}
values := map[string]any{}
for i, col := range cols {
col = strings.TrimSpace(col)
if !identifier.MatchString(col) {
return writeResult{}, fmt.Errorf("invalid column %q", col)
}
v, err := bindExpr(exprs[i], args, &pos)
if err != nil {
return writeResult{}, err
}
values[col] = v
}
// Empty optional UUID references are SQL NULL, never integer zero or an empty UUID.
st := &gorm.Statement{DB: db}
if err := st.Parse(target); err != nil {
return writeResult{}, err
}
for key, value := range values {
if f := st.Schema.LookUpField(key); f != nil && string(f.DataType) == "uuid" && !f.NotNull {
if value == "" || value == "0" {
values[key] = nil
}
}
}
plan, err := prepareMedia(db, logical, values)
if err != nil {
return writeResult{}, err
}
now := time.Now()
if _, ok := values["created_at"]; !ok {
values["created_at"] = now
}
if _, ok := values["updated_at"]; !ok {
values["updated_at"] = now
}
tx := db.Model(target)
suffix := strings.TrimSpace(q[end+1:])
switch {
case suffix == "":
case strings.EqualFold(suffix, "ON CONFLICT DO NOTHING"):
tx = tx.Clauses(clause.OnConflict{DoNothing: true})
default:
m := conflictPattern.FindStringSubmatch(suffix)
if m == nil {
return writeResult{}, fmt.Errorf("unsupported INSERT clause %q", suffix)
}
conflict := clause.OnConflict{TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}}}
for _, col := range strings.Split(m[1], ",") {
conflict.Columns = append(conflict.Columns, clause.Column{Name: strings.TrimSpace(col)})
}
updates, err := assignments(m[2], args, &pos)
if err != nil {
return writeResult{}, err
}
if logical == "settings" {
if _, err := prepareMedia(db, logical, updates); err != nil {
return writeResult{}, err
}
updates["file_id"] = values["file_id"]
}
updates["updated_at"] = now
conflict.DoUpdates = clause.Assignments(updates)
tx = tx.Clauses(conflict)
}
if pos != len(args) {
return writeResult{}, fmt.Errorf("INSERT argument count mismatch: consumed %d of %d", pos, len(args))
}
tx = tx.Clauses(clause.Returning{Columns: []clause.Column{{Name: "id"}}}).Create(&values)
if tx.Error != nil {
return writeResult{}, tx.Error
}
id := ToStr(values["id"])
nonempty := false
for _, ids := range plan {
if len(ids) > 0 {
nonempty = true
}
}
if nonempty && tx.RowsAffected > 0 {
if err := saveMediaPlan(db, logical, id, plan); err != nil {
return writeResult{}, err
}
}
return writeResult{id: id, affected: tx.RowsAffected}, nil
}
return writeResult{}, fmt.Errorf("unsupported write statement: use a GORM model operation")
}
// ValidUUID validates API UUID identifiers before they are used in SQL.
func ValidUUID(value string) bool {
var id pgtype.UUID
return len(value) == 36 && id.Scan(value) == nil && id.Valid
}
+41
View File
@@ -0,0 +1,41 @@
package store
import (
"past/internal/model"
"strings"
"testing"
)
func TestScopeQueryCoversJoinsAndSubqueriesWithoutChangingLiterals(t *testing.T) {
q := `SELECT 'FROM users',u.id,(SELECT COUNT(*) FROM photos WHERE album_id=a.id) AS n FROM users u LEFT JOIN albums AS a ON a.created_by=u.id WHERE u.couple_id IS DISTINCT FROM a.couple_id`
got, err := scopeQuery(q)
if err != nil {
t.Fatal(err)
}
if strings.Count(got, "WHERE deleted_at IS NULL") != 3 || !strings.Contains(got, `'FROM users'`) || !strings.Contains(got, `IS DISTINCT FROM a.couple_id`) {
t.Fatalf("incorrect read scope: %s", got)
}
if _, err := scopeQuery("SELECT * FROM unknown_table"); err == nil {
t.Fatal("unregistered table bypassed model scoping")
}
}
func TestSQLListsRespectLiteralsFunctionsAndPlaceholders(t *testing.T) {
parts, err := splitSQL(`COALESCE(?,NOW()),'a,b''c',?`)
if err != nil || len(parts) != 3 {
t.Fatalf("split=%v err=%v", parts, err)
}
pos := 0
if _, err := bindExpr(`CONCAT('?',?)`, []any{"value"}, &pos); err != nil || pos != 1 {
t.Fatalf("literal question mark treated as placeholder: pos=%d err=%v", pos, err)
}
}
func TestRepositoryModelsAreNotSharedBetweenRequests(t *testing.T) {
a, _ := freshModel("users")
b, _ := freshModel("users")
first := a.(*model.User)
first.Id = "request-a"
if b.(*model.User).Id != "" || tables["users"].(*model.User).Id != "" {
t.Fatal("mutable model state leaked between requests")
}
}
+126 -157
View File
@@ -1,185 +1,154 @@
package store package store
import ( import (
"context"
"database/sql"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/model"
) )
type addColumn struct { // InitSchema is transactional and serialized across concurrently starting servers.
Table, Column, DDL string // Models, rather than handwritten DDL, are the source of truth for all tables.
}
// InitSchema 对应 install.php 的 initDatabase():建表、补列、默认数据、历史数据修正。
// PHP 版本每次请求都执行;Go 版本只在启动时执行一次。对已迁移的线上库幂等。
func (db *DB) InitSchema() error { func (db *DB) InitSchema() error {
// 会话级设置(SET NAMES / FOREIGN_KEY_CHECKS)必须落在同一条连接上,因此整个初始化独占一条连接 return db.ORM.Transaction(func(tx *gorm.DB) error {
conn, err := db.DB.Conn(context.Background()) if err := tx.Exec("SELECT pg_advisory_xact_lock(520001)").Error; err != nil {
if err != nil {
return err
}
defer conn.Close()
d := &connRunner{conn}
if _, err := d.Exec("SET NAMES utf8mb4"); err != nil {
return err
}
// 与 PHP 一致:建表阶段关闭外键检查(表之间存在前向引用),结束后恢复
if _, err := d.Exec("SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer d.Exec("SET FOREIGN_KEY_CHECKS = 1")
for _, stmt := range createTables {
if _, err := d.Exec(stmt); err != nil {
name := strings.Fields(stmt)[5]
return fmt.Errorf("建表 %s 失败: %w", name, err)
}
}
// 默认管理员(密码 admin123)
cnt, err := d.QueryOne("SELECT COUNT(*) as cnt FROM admins")
if err != nil {
return err
}
if cnt.Int("cnt") == 0 {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
// 与 PHP password_hash 一致使用 $2y$ 前缀(算法相同)
phpHash := "$2y$" + strings.TrimPrefix(string(hash), "$2a$")
if _, err := d.Exec("INSERT INTO admins (username, password) VALUES (?, ?)", "admin", phpHash); err != nil {
return err return err
} }
} // Preserve existing rows when upgrading the earlier hyphen prefix.
for _, m := range model.Models() {
st := &gorm.Statement{DB: tx}
if err := st.Parse(m); err != nil {
return err
}
current := st.Schema.Table
previous := "past-" + strings.TrimPrefix(current, "past_")
if !tx.Migrator().HasTable(previous) {
continue
}
if tx.Migrator().HasTable(current) {
return fmt.Errorf("同时存在 %s 与 %s,停止迁移以避免覆盖数据", previous, current)
}
if err := tx.Migrator().RenameTable(previous, current); err != nil {
return err
}
// The default deleted_at index derives its name from the table name.
oldIndex := NamingStrategy.IndexName(previous, "deleted_at")
newIndex := NamingStrategy.IndexName(current, "deleted_at")
if tx.Migrator().HasIndex(current, oldIndex) && !tx.Migrator().HasIndex(current, newIndex) {
if err := tx.Migrator().RenameIndex(current, oldIndex, newIndex); err != nil {
return err
}
}
}
if err := tx.AutoMigrate(model.Models()...); err != nil {
return fmt.Errorf("GORM 自动迁移失败: %w", err)
}
// NULL couple_id denotes personal records. Partial indexes allow recreating
// a deleted record while preserving history and enforce personal date uniqueness.
for _, stmt := range []string{
`CREATE UNIQUE INDEX IF NOT EXISTS ux_period_personal_date ON "past_period_daily_record" (user_id,record_date) WHERE deleted_at IS NULL AND couple_id IS NULL`,
`CREATE INDEX IF NOT EXISTS ix_unbind_deadline ON "past_unbind_request" (status,cooling_until) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS ix_smoking_recent ON "past_smoking_entry" (user_id,created_at) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS ix_activity_recent ON "past_activity_log" (couple_id,created_at DESC) WHERE deleted_at IS NULL`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
var count int64
if err := tx.Model(&model.Admin{}).Count(&count).Error; err != nil {
return err
}
if count == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte("past"), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := model.Admin{Username: "past", Password: "$2y$" + strings.TrimPrefix(string(hash), "$2a$")}
if err := tx.Create(&admin).Error; err != nil {
return err
}
}
defaults := map[string]string{"site_title": "我们past", "site_subtitle": "记录每一个心动瞬间", "theme_color": "#ff6b81", "bg_style": "gradient", "show_timeline": "1", "show_album": "1", "show_diary": "1", "show_anniversary": "1", "show_songs": "1", "show_checkin": "1"}
for key, value := range defaults {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
return err
}
}
// 补列(含 users.phone 的特殊迁移逻辑) if err := initStoragePlans(tx); err != nil {
for _, ac := range addColumns { return err
exists, err := d.columnExists(ac.Table, ac.Column) }
// A database sequence allocates display IDs atomically under registration load.
if err := tx.Exec(`CREATE SEQUENCE IF NOT EXISTS past_user_display_id START WITH 520001`).Error; err != nil {
return err
}
return nil
})
}
// ResetDefaultAdmin preserves the original administrator ID when renaming it.
// It is an explicit maintenance action, never an automatic password reset at startup.
func (db *DB) ResetDefaultAdmin() error {
hash, err := bcrypt.GenerateFromPassword([]byte("past"), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.ORM.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(520001)").Error; err != nil {
return err
}
var admin model.Admin
err := tx.Where("username = ?", "past").First(&admin).Error
if err == gorm.ErrRecordNotFound {
err = tx.Where("username = ?", "admin").First(&admin).Error
}
if err == gorm.ErrRecordNotFound {
return tx.Create(&model.Admin{Username: "past", Password: string(hash)}).Error
}
if err != nil { if err != nil {
return err return err
} }
if exists { return tx.Model(&admin).Updates(map[string]any{"username": "past", "password": string(hash)}).Error
continue })
} }
if _, err := d.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", ac.Table, ac.Column, ac.DDL)); err != nil {
return fmt.Errorf("ALTER TABLE %s ADD COLUMN %s: %w", ac.Table, ac.Column, err)
}
if ac.Table == "users" && ac.Column == "phone" {
d.Exec("UPDATE users SET phone = username WHERE phone = '' OR phone IS NULL")
d.Exec("UPDATE users SET phone = CONCAT(id, '_old') WHERE phone = '' OR phone IS NULL")
d.Exec("CREATE UNIQUE INDEX idx_users_phone ON users(phone)")
}
}
// period 相关表 couple_id 允许 NULL:未绑定情侣时按 NULL 保存(对齐 cidOrNil 的设计)。 func initStoragePlans(tx *gorm.DB) error {
// 旧库建表时为 NOT NULL,这里做幂等的放宽约束迁移。 var count int64
for _, t := range []string{"period_records", "period_daily_records", "period_sex_records"} { if err := tx.Unscoped().Model(&model.StoragePlan{}).Count(&count).Error; err != nil {
if _, err := d.Exec("ALTER TABLE " + t + " MODIFY couple_id INT NULL"); err != nil {
slog.Debug("period 表 couple_id 修改为 NULL 失败", "table", t, "err", err)
}
}
// 确保 username 唯一索引(PHP 用 try/catch 忽略已存在)
if _, err := d.Exec("CREATE UNIQUE INDEX idx_users_username ON users(username)"); err != nil {
slog.Debug("idx_users_username 已存在或创建失败", "err", err)
}
// 修正已有照片 created_by 为 NULL 的记录(补齐为第一个用户)
np, err := d.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE created_by IS NULL")
if err != nil {
return err return err
} }
if np.Int("cnt") > 0 { if count == 0 {
first, _ := d.QueryOne("SELECT id FROM users LIMIT 1") for i, key := range []string{"month", "year"} {
if first != nil { months, price, name := int64(1), int64(500), "包月无限空间"
d.Exec("UPDATE photos SET created_by = ? WHERE created_by IS NULL", first.Int("id")) if key == "year" {
months, price, name = 12, 2990, "包年无限空间"
}
var setting model.Setting
if err := tx.Where("key=?", "storage_plan_"+key+"_price").Find(&setting).Error; err != nil {
return err
}
if v := ToInt(setting.Value); v > 0 {
price = v
}
if err := tx.Create(&model.StoragePlan{Key: key, Name: name, Months: months, PriceCents: price, Enabled: true, SortOrder: int64(i)}).Error; err != nil {
return err
}
} }
} }
var users []model.User
// 修正已有错误设置值 if err := tx.Where("storage_vip_expire_at>NOW() AND storage_plan IN ?", []string{"", "month", "year"}).Where("NOT EXISTS (SELECT 1 FROM past_storage_entitlement e WHERE e.user_id=past_user.id AND e.deleted_at IS NULL)").Find(&users).Error; err != nil {
d.Exec("UPDATE settings SET `value` = ? WHERE `key` = ? AND `value` = ?", "我们past", "site_title", "叁叁肆计划巡演记录视频")
// 默认站点设置
defaults := [][2]string{
{"site_title", "我们past"},
{"site_subtitle", "记录每一个心动瞬间"},
{"theme_color", "#ff6b81"},
{"bg_style", "gradient"},
{"show_timeline", "1"},
{"show_album", "1"},
{"show_diary", "1"},
{"show_anniversary", "1"},
{"show_songs", "1"},
{"show_checkin", "1"},
}
for _, kv := range defaults {
if _, err := d.Exec("INSERT IGNORE INTO settings (`key`, `value`) VALUES (?, ?)", kv[0], kv[1]); err != nil {
return err
}
}
// 补全历史数据:last_login_at 为 NULL 的用户设为 created_at
d.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL")
// 爱爱记录 measure 字段 '无性行为' → '无措施'
d.Exec("UPDATE period_sex_records SET measure = '无措施' WHERE measure = '无性行为'")
// index.php 的 diary_list/diary_detail 引用了 diary_likes / diary_favorites,但 install.php 从未建表
// (线上库应已存在)。这里仅在缺失时补建,避免全新环境 500;已存在则不做任何改动。
for _, t := range []string{"diary_likes", "diary_favorites"} {
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS ` + t + ` (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_diary_user (diary_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil {
return err
}
}
if _, err := d.Exec("UPDATE unbind_requests r JOIN couples c ON c.id=r.couple_id SET r.status=CASE WHEN c.status='unbound' THEN 'completed' ELSE 'cooling' END,r.cooling_until=CASE WHEN c.status='unbound' THEN NULL ELSE DATE_ADD(NOW(),INTERVAL 72 HOUR) END WHERE r.status='agreed'"); err != nil {
return err return err
} }
idx, err := d.Query("SHOW INDEX FROM unbind_requests WHERE Key_name='unbind_deadline'") for _, user := range users {
if err != nil { ent := model.StorageEntitlement{UserId: user.Id, SourceKey: "legacy:" + user.Id, PlanKey: user.StoragePlan, PlanName: "原无限空间会员", StartsAt: time.Now(), ExpiresAt: *user.StorageVipExpireAt}
return err if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ent).Error; err != nil {
}
if len(idx) == 0 {
if _, err := d.Exec("CREATE INDEX unbind_deadline ON unbind_requests(status,cooling_until)"); err != nil {
return err
}
}
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS smoking_wallets (user_id INT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS smoking_entries (id INT AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,kind VARCHAR(20) NOT NULL,quantity INT NOT NULL,delta INT NOT NULL,balance INT NOT NULL,amount_cents INT NOT NULL DEFAULT 0,note VARCHAR(200) NOT NULL DEFAULT '',request_key VARCHAR(64) NOT NULL,payload_hash CHAR(64) NOT NULL,original_id INT DEFAULT NULL,undone TINYINT NOT NULL DEFAULT 0,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY smoking_personal_request(user_id,request_key),KEY smoking_personal_recent(user_id,created_at),FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
} {
if _, err := d.Exec(stmt); err != nil {
return err return err
} }
} }
return nil return nil
} }
// connRunner 让单条 *sql.Conn 具备 Query/QueryOne/Exec 能力
type connRunner struct{ *sql.Conn }
func (c *connRunner) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, false, query, args...)
}
func (c *connRunner) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, false, query, args...)
}
func (c *connRunner) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (d *connRunner) columnExists(table, column string) (bool, error) {
rows, err := d.Query("SHOW COLUMNS FROM "+table+" LIKE ?", column)
if err != nil {
return false, err
}
return len(rows) > 0, nil
}
-514
View File
@@ -1,514 +0,0 @@
package store
// 本文件由 backend/install.php 的 SQL 语句自动提取生成,表结构与线上 PHP 版本保持一致。
// 请勿手工改动字段定义;如需新增字段,追加到 addColumns 中。
// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。
var createTables = []string{
`CREATE TABLE IF NOT EXISTS unbinding_file_cleanup (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(20) UNIQUE,
openid VARCHAR(100) UNIQUE,
phone VARCHAR(50) DEFAULT '',
username VARCHAR(100) DEFAULT '',
password VARCHAR(255) DEFAULT '',
nickname VARCHAR(100) DEFAULT '',
avatar VARCHAR(500) DEFAULT '',
gender VARCHAR(10) DEFAULT '',
couple_id INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS couples (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_space_id VARCHAR(50) UNIQUE,
user1_id INT NOT NULL,
user2_id INT NOT NULL,
love_start_date VARCHAR(20) DEFAULT '',
love_story TEXT,
status VARCHAR(20) DEFAULT 'active',
data_deleted TINYINT DEFAULT 0,
period_visible TINYINT DEFAULT 0 COMMENT '男方是否可见女生日历 0-不可见 1-可见',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invitations (
id INT AUTO_INCREMENT PRIMARY KEY,
from_user_id INT NOT NULL,
to_user_id INT DEFAULT NULL,
to_phone VARCHAR(50) DEFAULT '',
status VARCHAR(20) DEFAULT 'pending',
message TEXT,
invite_code VARCHAR(100) DEFAULT '',
type VARCHAR(20) DEFAULT 'phone',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS unbind_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
requester_id INT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME DEFAULT NULL,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invite_links (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
used INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS timeline (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
event_date VARCHAR(20) NOT NULL,
location VARCHAR(200) DEFAULT '',
images TEXT,
icon VARCHAR(20) DEFAULT '❤️',
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS albums (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
cover VARCHAR(500) DEFAULT '',
sort_order INT DEFAULT 0,
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS photos (
id INT AUTO_INCREMENT PRIMARY KEY,
album_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
description TEXT,
taken_at VARCHAR(50) DEFAULT '',
location VARCHAR(200) DEFAULT '',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
activity VARCHAR(200) DEFAULT '',
batch_id VARCHAR(50) DEFAULT '',
sort_order INT DEFAULT 0,
created_by INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diaries (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
mood VARCHAR(20) DEFAULT '😊',
weather VARCHAR(20) DEFAULT '☀️',
diary_date VARCHAR(20) NOT NULL,
images TEXT,
is_anniversary INT DEFAULT 0,
region VARCHAR(100) DEFAULT '' COMMENT '发布地区',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS songs (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
song_name VARCHAR(255) NOT NULL,
artist VARCHAR(255) NOT NULL,
note VARCHAR(120) DEFAULT '',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkins (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
icon VARCHAR(20) DEFAULT '✅',
type VARCHAR(10) DEFAULT 'dual',
frequency VARCHAR(10) DEFAULT 'daily',
is_completed INT DEFAULT 0,
completed_by_me INT DEFAULT 0,
completed_by_partner INT DEFAULT 0,
my_checkin_time DATETIME,
partner_checkin_time DATETIME,
my_streak INT DEFAULT 0,
partner_streak INT DEFAULT 0,
my_photo VARCHAR(500) DEFAULT '',
partner_photo VARCHAR(500) DEFAULT '',
my_note VARCHAR(200) DEFAULT '',
partner_note VARCHAR(200) DEFAULT '',
completed_at DATETIME,
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkin_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
checkin_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
photo VARCHAR(500) DEFAULT '',
note VARCHAR(200) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
stock INT NOT NULL DEFAULT 0,
checkin_id INT DEFAULT NULL,
UNIQUE KEY smoking_owner (couple_id, user_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
account_id INT NOT NULL,
kind VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
delta INT NOT NULL,
balance INT NOT NULL,
amount_cents INT NOT NULL DEFAULT 0,
note VARCHAR(200) NOT NULL DEFAULT '',
request_key VARCHAR(64) NOT NULL,
payload_hash CHAR(64) NOT NULL,
checkin_log_id INT DEFAULT NULL,
original_id INT DEFAULT NULL,
undone TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY smoking_request (account_id, request_key),
KEY smoking_recent (account_id, created_at),
FOREIGN KEY (account_id) REFERENCES smoking_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
cycle_length INT DEFAULT 28,
period_length INT DEFAULT 5,
symptoms VARCHAR(500) DEFAULT '',
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_daily_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
period ENUM('0','1','2','3') DEFAULT '0' COMMENT '0-无 1-少量 2-中量 3-大量',
sex ENUM('0','1') DEFAULT '0' COMMENT '0-无 1-爱爱',
symptoms VARCHAR(200) DEFAULT '' COMMENT '症状,逗号分隔',
mood VARCHAR(100) DEFAULT '' COMMENT '心情',
discharge VARCHAR(100) DEFAULT '' COMMENT '白带',
temperature DECIMAL(4,1) DEFAULT NULL COMMENT '体温',
weight DECIMAL(5,1) DEFAULT NULL COMMENT '体重',
note TEXT COMMENT '备注',
period_detail TEXT COMMENT '经期详情JSON(量/颜色/疼痛)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_couple (record_date, couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_sex_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
record_time TIME DEFAULT NULL COMMENT '记录时间,精确到分钟',
measure VARCHAR(100) DEFAULT '' COMMENT '措施类型',
measure_custom VARCHAR(200) DEFAULT '' COMMENT '其他措施自定义内容',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_date_couple (record_date, couple_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS settings (
` + "`" + `key` + "`" + ` VARCHAR(255) PRIMARY KEY,
` + "`" + `value` + "`" + ` TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diary_comments (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
parent_id INT DEFAULT 0 COMMENT '回复的评论ID,0为顶级评论',
reply_to_user_id INT DEFAULT NULL COMMENT '被回复的用户ID',
reply_to_username VARCHAR(100) DEFAULT '' COMMENT '被回复的用户昵称',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (diary_id) REFERENCES diaries(id) ON DELETE CASCADE,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_diary (diary_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscribe_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
template_id VARCHAR(100) NOT NULL,
template_type VARCHAR(50) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_template (user_id, template_type),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
description VARCHAR(500) NOT NULL,
detail TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_couple_time (couple_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS user_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(128) NOT NULL UNIQUE,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscribe_message_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_id INT NOT NULL DEFAULT 0,
couple_id INT DEFAULT NULL,
user_id INT NOT NULL DEFAULT 0,
to_user_id INT NOT NULL,
to_openid VARCHAR(128) NOT NULL DEFAULT '',
action_type VARCHAR(50) NOT NULL DEFAULT '',
message_title VARCHAR(255) NOT NULL DEFAULT '',
message_content TEXT,
template_type VARCHAR(50) NOT NULL,
template_id VARCHAR(128) NOT NULL DEFAULT '',
page VARCHAR(255) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL,
error_code INT DEFAULT NULL,
http_status INT DEFAULT NULL,
error_msg TEXT,
attempts INT NOT NULL DEFAULT 0,
duration_ms INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL,
INDEX idx_status_time (status, created_at),
INDEX idx_recipient_time (to_user_id, created_at),
INDEX idx_openid (to_openid),
INDEX idx_event_time (action_type, created_at),
INDEX idx_activity (activity_id),
INDEX idx_couple (couple_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS notify_log (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
template_type VARCHAR(50) NOT NULL,
to_user_id INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT '',
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL,
UNIQUE KEY uk_activity (activity_id, to_user_id),
INDEX idx_status_time (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pets (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL DEFAULT '小狗狗',
breed VARCHAR(20) NOT NULL DEFAULT 'golden' COMMENT 'golden/husky/corgi/shiba',
gender TINYINT NOT NULL DEFAULT 1 COMMENT '1-男 2-女',
stage INT NOT NULL DEFAULT 1 COMMENT '1幼犬 2少年 3青年 4成年 5终极',
level INT NOT NULL DEFAULT 1,
exp INT NOT NULL DEFAULT 0,
happiness INT NOT NULL DEFAULT 100,
hunger INT NOT NULL DEFAULT 100,
energy INT NOT NULL DEFAULT 100,
cleanliness INT NOT NULL DEFAULT 100,
adopted_at DATE NOT NULL,
created_by INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dailies (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
date DATE NOT NULL,
feed_u1 TINYINT DEFAULT 0,
feed_u2 TINYINT DEFAULT 0,
water_u1 TINYINT DEFAULT 0,
water_u2 TINYINT DEFAULT 0,
walk_u1 TINYINT DEFAULT 0,
walk_u2 TINYINT DEFAULT 0,
bath_u1 TINYINT DEFAULT 0,
bath_u2 TINYINT DEFAULT 0,
sleep_u1 TINYINT DEFAULT 0,
sleep_u2 TINYINT DEFAULT 0,
play_u1 TINYINT DEFAULT 0,
play_u2 TINYINT DEFAULT 0,
all_done TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_date (couple_id, date),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
achievement_key VARCHAR(50) NOT NULL,
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_ach (couple_id, achievement_key),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_items (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
item_key VARCHAR(50) NOT NULL,
item_type VARCHAR(20) NOT NULL COMMENT 'accessory/collar/scarf/background/consumable',
quantity INT DEFAULT 1,
equipped TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple (couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dynamics (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action VARCHAR(30) NOT NULL,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple_time (couple_id, created_at DESC),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS storage_orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
couple_id INT DEFAULT NULL,
out_trade_no VARCHAR(64) NOT NULL UNIQUE,
plan_key VARCHAR(20) NOT NULL COMMENT 'month/year',
product_id VARCHAR(64) NOT NULL DEFAULT '',
amount_cents INT NOT NULL DEFAULT 0,
env TINYINT NOT NULL DEFAULT 1 COMMENT '0-正式 1-沙箱',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/success/closed',
wx_order_id VARCHAR(64) NOT NULL DEFAULT '',
attach TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
paid_at DATETIME DEFAULT NULL,
INDEX idx_user (user_id),
INDEX idx_status (status, created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。
var addColumns = []addColumn{
{"unbind_requests", "cancelled_at", "DATETIME DEFAULT NULL"},
{"unbind_requests", "cancelled_by", "INT DEFAULT NULL"},
{"unbind_requests", "cooling_until", "DATETIME DEFAULT NULL"},
{"unbind_requests", "completed_at", "DATETIME DEFAULT NULL"},
{"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"},
{"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"},
{"checkins", "completed_by_me", "INT DEFAULT 0"},
{"checkins", "completed_by_partner", "INT DEFAULT 0"},
{"checkins", "my_checkin_time", "DATETIME"},
{"checkins", "partner_checkin_time", "DATETIME"},
{"checkins", "my_streak", "INT DEFAULT 0"},
{"checkins", "partner_streak", "INT DEFAULT 0"},
{"checkins", "my_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "partner_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "my_note", "VARCHAR(200) DEFAULT ''"},
{"checkins", "partner_note", "VARCHAR(200) DEFAULT ''"},
{"period_daily_records", "period_detail", "TEXT COMMENT '经期详情JSON(量/颜色/疼痛)'"},
{"users", "openid", "VARCHAR(100) UNIQUE"},
{"users", "phone", "VARCHAR(50) DEFAULT ''"},
{"users", "gender", "VARCHAR(10) DEFAULT ''"},
{"users", "period_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '姨姨:是否对伴侣可见'"},
{"users", "age", "INT DEFAULT 0"},
{"users", "birthday", "VARCHAR(20) DEFAULT ''"},
{"users", "last_login_at", "DATETIME DEFAULT NULL"},
{"users", "region", "VARCHAR(100) DEFAULT '' COMMENT '地区,如云南楚雄'"},
{"users", "location", "VARCHAR(200) DEFAULT '' COMMENT '模糊定位,如纬度,经度'"},
{"users", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度'"},
{"users", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度'"},
{"users", "last_login_location", "VARCHAR(200) DEFAULT '' COMMENT '最近一次登录地址'"},
{"users", "ip_geo_date", "DATE DEFAULT NULL COMMENT '最后一次 IP 定位日期(每日限流)'"},
{"users", "last_ip", "VARCHAR(64) DEFAULT '' COMMENT '最后一次 IP 定位的客户端 IP 地址'"},
{"invitations", "to_phone", "VARCHAR(50) DEFAULT ''"},
{"invitations", "invite_code", "VARCHAR(100) DEFAULT ''"},
{"invitations", "type", "VARCHAR(20) DEFAULT 'phone'"},
{"couples", "couple_space_id", "VARCHAR(50) UNIQUE"},
{"couples", "period_source_user_id", "INT DEFAULT NULL COMMENT '绑定前姨姨记录选定的来源用户'"},
{"couples", "data_deleted", "TINYINT DEFAULT 0"},
{"photos", "taken_at", "VARCHAR(50) DEFAULT ''"},
{"photos", "location", "VARCHAR(200) DEFAULT ''"},
{"photos", "batch_id", "VARCHAR(50) DEFAULT ''"},
{"photos", "activity", "VARCHAR(200) DEFAULT ''"},
{"photos", "created_by", "INT DEFAULT NULL"},
{"diaries", "region", "VARCHAR(100) DEFAULT '' COMMENT '发布地区' AFTER is_anniversary"},
{"diaries", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER region"},
{"diaries", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"diaries", "location", "VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称' AFTER lng"},
{"albums", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER sort_order"},
{"albums", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"albums", "location", "VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称' AFTER lng"},
{"photos", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER location"},
{"photos", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"timeline", "location", "VARCHAR(200) DEFAULT '' AFTER event_date"},
{"timeline", "repeat_interval", "INT DEFAULT 0 AFTER location"},
{"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"},
{"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"},
{"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"},
{"users", "session_key", "VARCHAR(128) DEFAULT '' COMMENT '微信会话密钥,用于虚拟支付签名'"},
{"users", "storage_vip_expire_at", "DATETIME DEFAULT NULL COMMENT '存储会员到期时间,NULL=未开通'"},
{"users", "storage_quota_mb", "INT DEFAULT 0 COMMENT '自定义存储容量MB,0=使用默认免费额度'"},
{"users", "storage_plan", "VARCHAR(20) DEFAULT '' COMMENT '当前存储套餐 month/year/空=未开通'"},
{"users", "storage_plan_started_at", "DATETIME DEFAULT NULL COMMENT '存储套餐开通时间'"},
{"users", "smoking_card_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '抽烟卡片是否显示:1显示 0隐藏'"},
}
+80
View File
@@ -0,0 +1,80 @@
package store
import (
"gorm.io/gorm"
"past/internal/model"
"time"
)
type StorageAllowance struct {
PlanName string
FreeMb int64
QuotaMb int64
Unlimited bool
IsVip bool
ExpiresAt *time.Time
}
// StorageSpace is shared with the current partner. Paid capacities use the
// largest active allowance, not their sum; each grant retains its own expiry.
func StorageSpace(db *gorm.DB, userId string) (StorageAllowance, error) {
out := StorageAllowance{FreeMb: 200, QuotaMb: 200}
var user model.User
if err := db.First(&user, "id=?", userId).Error; err != nil {
return out, err
}
if user.StorageQuotaMb > 0 {
out.FreeMb = user.StorageQuotaMb
} else {
var setting model.Setting
r := db.Where("key=?", "storage_free_mb").Find(&setting)
if r.Error != nil {
return out, r.Error
}
if r.RowsAffected > 0 && setting.Value != "" {
out.FreeMb = max(0, ToInt(setting.Value))
}
}
out.QuotaMb = out.FreeMb
owners, err := UploadOwners(db, userId)
if err != nil {
return out, err
}
var grants []model.StorageEntitlement
if err := db.Where("user_id IN ? AND starts_at<=NOW() AND expires_at>NOW()", owners).Find(&grants).Error; err != nil {
return out, err
}
apply := func(quota int64, expires time.Time, name string) {
out.IsVip = true
if quota == 0 {
if !out.Unlimited {
out.ExpiresAt = nil
}
out.Unlimited = true
} else if out.Unlimited {
return
} else if quota > out.QuotaMb {
out.QuotaMb = quota
out.ExpiresAt = nil
} else if quota < out.QuotaMb {
return
}
if out.ExpiresAt == nil || expires.After(*out.ExpiresAt) {
e := expires
out.ExpiresAt = &e
out.PlanName = name
}
}
for _, g := range grants {
apply(g.QuotaMb, g.ExpiresAt, g.PlanName)
}
// Compatibility for legacy membership fields written before this version.
var legacy []model.User
if err := db.Where("id IN ? AND storage_vip_expire_at>NOW() AND storage_plan IN ?", owners, []string{"", "month", "year"}).Where("NOT EXISTS (SELECT 1 FROM past_storage_entitlement e WHERE e.user_id=past_user.id AND e.deleted_at IS NULL)").Find(&legacy).Error; err != nil {
return out, err
}
for _, u := range legacy {
apply(0, *u.StorageVipExpireAt, "原无限空间会员")
}
return out, nil
}
+3 -125
View File
@@ -1,20 +1,12 @@
package store package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 仅删除当前情侣空间的数据,不按用户 ID 删除其他空间或个人账本。 // 仅删除当前情侣空间的数据,不按用户 ID 删除其他空间或个人账本。
var sharedDataDeleteStatements = []string{ var sharedDataDeleteStatements = []string{
"DELETE l FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id WHERE a.couple_id = ?", "DELETE FROM smoking_logs WHERE account_id IN (SELECT id FROM smoking_accounts WHERE couple_id = ?)",
"DELETE FROM smoking_accounts WHERE couple_id = ?", "DELETE FROM smoking_accounts WHERE couple_id = ?",
"DELETE FROM diary_comments WHERE couple_id = ?", "DELETE FROM diary_comments WHERE couple_id = ?",
"DELETE FROM checkin_logs WHERE couple_id = ?", "DELETE FROM checkin_logs WHERE couple_id = ?",
"DELETE p FROM photos p JOIN albums a ON a.id=p.album_id WHERE a.couple_id = ?", "DELETE FROM photos WHERE album_id IN (SELECT id FROM albums WHERE couple_id = ?)",
"DELETE FROM albums WHERE couple_id = ?", "DELETE FROM albums WHERE couple_id = ?",
"DELETE FROM diaries WHERE couple_id = ?", "DELETE FROM diaries WHERE couple_id = ?",
"DELETE FROM timeline WHERE couple_id = ?", "DELETE FROM timeline WHERE couple_id = ?",
@@ -31,119 +23,5 @@ var sharedDataDeleteStatements = []string{
"DELETE FROM activity_log WHERE couple_id = ?", "DELETE FROM activity_log WHERE couple_id = ?",
"DELETE FROM notify_log WHERE couple_id = ?", "DELETE FROM notify_log WHERE couple_id = ?",
"DELETE FROM subscribe_message_logs WHERE couple_id = ?", "DELETE FROM subscribe_message_logs WHERE couple_id = ?",
"DELETE FROM settings WHERE `key` IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))", "DELETE FROM settings WHERE key IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))",
}
func queueUnboundFiles(tx *Tx, cid int64, space string) error {
queries := []string{
"SELECT path AS media FROM photos WHERE album_id IN (SELECT id FROM albums WHERE couple_id = ?)",
"SELECT cover AS media FROM albums WHERE couple_id = ?",
"SELECT images AS media FROM diaries WHERE couple_id = ?",
"SELECT images AS media FROM timeline WHERE couple_id = ?",
"SELECT bg_image AS media FROM timeline WHERE couple_id = ?",
"SELECT `value` AS media FROM settings WHERE `key` = CONCAT('bg_', ?)",
}
paths := map[string]bool{}
for _, query := range queries {
records, err := tx.Query(query, cid)
if err != nil {
return err
}
for _, record := range records {
value := record.Str("media")
var images []string
if json.Unmarshal([]byte(value), &images) == nil {
for _, image := range images {
if p := cleanupPath(image); p != "" {
paths[p] = true
}
}
} else if p := cleanupPath(value); p != "" {
paths[p] = true
}
}
}
// 清除该空间目录内已失去引用的附件,保留用户头像和个人目录。
if space == fmt.Sprintf("CP%06d", cid) {
paths["couples/"+space+"/"] = true
}
for path := range paths {
if _, err := tx.Exec("INSERT INTO unbinding_file_cleanup (couple_id,path) VALUES (?,?)", cid, path); err != nil {
return err
}
}
return nil
}
func cleanupPath(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "/backend/")
value = strings.TrimPrefix(value, "backend/")
value = strings.TrimPrefix(value, "/")
if !strings.HasPrefix(value, "uploads/") {
return ""
}
value = strings.SplitN(value, "?", 2)[0]
value = strings.TrimPrefix(value, "uploads/")
if strings.ContainsAny(value, "\\:\x00") {
return ""
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." || segment == "" {
return ""
}
}
if strings.HasPrefix(value, "avatars/") {
return ""
}
return value
}
// 文件删除在数据库提交之后执行;失败保留任务,下次启动或轮询重试。
func (db *DB) cleanUnboundFiles() error {
if db.UploadDir == "" {
return nil
}
tasks, err := db.Query("SELECT id,couple_id,path FROM unbinding_file_cleanup ORDER BY id LIMIT 500")
if err != nil {
return err
}
for _, task := range tasks {
path := task.Str("path")
directory := path == "couples/"+fmt.Sprintf("CP%06d", task.Int("couple_id"))+"/"
if !directory && cleanupPath("uploads/"+path) != path {
return fmt.Errorf("invalid unbinding cleanup path %q", path)
}
target := filepath.Join(db.UploadDir, filepath.FromSlash(path))
// 不跟随目录链接,避免删除上传根目录之外的数据。
parent := filepath.Dir(target)
if directory {
parent = filepath.Dir(strings.TrimSuffix(target, string(filepath.Separator)))
}
root, rootErr := filepath.EvalSymlinks(db.UploadDir)
actual, parentErr := filepath.EvalSymlinks(parent)
if rootErr != nil {
return rootErr
}
if parentErr == nil {
rel, err := filepath.Rel(root, actual)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("cleanup outside upload directory")
}
} else if !os.IsNotExist(parentErr) {
return parentErr
}
if directory {
err = os.RemoveAll(target)
} else {
err = os.Remove(target)
}
if err != nil && !os.IsNotExist(err) {
return err
}
if _, err = db.Exec("DELETE FROM unbinding_file_cleanup WHERE id = ?", task.Int("id")); err != nil {
return err
}
}
return nil
} }
@@ -1,61 +1,17 @@
package store package store
import ( import (
"database/sql/driver" "strings"
"os"
"past/internal/testdb"
"path/filepath"
"testing" "testing"
) )
func TestCleanupPathsProtectPersonalAndOutsideFiles(t *testing.T) { func TestSharedDeletionRemainsScopedToTheCurrentSpace(t *testing.T) {
for _, input := range []string{"uploads/../secret", "uploads/users/1/../../secret", "uploads/avatars/520001/avatar.jpg", "https://example.com/image.jpg", "uploads/C:\\secret", "uploads//secret"} { for _, q := range sharedDataDeleteStatements {
if cleanupPath(input) != "" { if !strings.Contains(q, "WHERE") || !strings.Contains(q, "?") {
t.Fatalf("unsafe or personal path accepted: %q", input) t.Fatalf("unscoped deletion: %s", q)
}
if strings.Contains(q, "smoking_entries") || strings.Contains(q, "smoking_wallets") {
t.Fatalf("personal inventory must survive unbinding: %s", q)
} }
} }
if got := cleanupPath("/backend/uploads/users/1/diary/a.jpg?v=1"); got != "users/1/diary/a.jpg" {
t.Fatal(got)
}
}
func TestUnbindingFilesDeleteOnlyQueuedMediaAndRetryMissingFiles(t *testing.T) {
root := t.TempDir()
shared := filepath.Join(root, "users/1/diary/a.jpg")
avatar := filepath.Join(root, "avatars/520001/avatar.jpg")
for _, p := range []string{shared, avatar} {
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("image"), 0600); err != nil {
t.Fatal(err)
}
}
db, _ := testdb.Open(t, []testdb.Step{
{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/a.jpg"}, {int64(2), int64(7), "users/1/diary/already-gone.jpg"}}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(1)}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(2)}},
})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(shared); !os.IsNotExist(err) {
t.Fatal("shared attachment remains")
}
if _, err := os.Stat(avatar); err != nil {
t.Fatal("personal avatar deleted")
}
}
func TestCleanupFailureRetainsTaskForRetry(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "users/1/diary/folder")
if err := os.MkdirAll(target, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "child"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
db, _ := testdb.Open(t, []testdb.Step{{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/folder"}}}})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err == nil {
t.Fatal("failure ignored, task must remain queued")
}
} }
+105 -2
View File
@@ -6,12 +6,16 @@ import (
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"fmt" "fmt"
"gorm.io/gorm/schema"
"io" "io"
"past/internal/model"
"reflect" "reflect"
"regexp"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
) )
type Step struct { type Step struct {
@@ -78,7 +82,9 @@ func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
} }
s := c.s.Steps[c.s.Index] s := c.s.Steps[c.s.Index]
c.s.Index++ c.s.Index++
if !strings.Contains(query, s.Match) { rawQuery := query
query = normalizeSQL(query)
if !strings.Contains(compactSQL(query), compactSQL(normalizeSQL(s.Match))) {
return s, fmt.Errorf("expected SQL containing %q got %s", s.Match, query) return s, fmt.Errorf("expected SQL containing %q got %s", s.Match, query)
} }
if s.Args != nil { if s.Args != nil {
@@ -86,7 +92,8 @@ func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
for i, a := range args { for i, a := range args {
values[i] = a.Value values[i] = a.Value
} }
if !reflect.DeepEqual(values, s.Args) { values = businessArgs(values)
if !equalArgs(values, s.Args) {
return s, fmt.Errorf("SQL args %v wanted %v", values, s.Args) return s, fmt.Errorf("SQL args %v wanted %v", values, s.Args)
} }
} }
@@ -99,6 +106,7 @@ func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
return s, err return s, err
} }
} }
_ = rawQuery
return s, s.Err return s, s.Err
} }
func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue) (driver.Rows, error) { func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue) (driver.Rows, error) {
@@ -106,6 +114,9 @@ func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.HasPrefix(q, "INSERT") && len(s.Columns) == 0 {
return &rows{columns: []string{"id"}, data: [][]driver.Value{{fmt.Sprint(s.ID)}}}, nil
}
return &rows{columns: s.Columns, data: s.Rows}, nil return &rows{columns: s.Columns, data: s.Rows}, nil
} }
func (c *conn) ExecContext(_ context.Context, q string, a []driver.NamedValue) (driver.Result, error) { func (c *conn) ExecContext(_ context.Context, q string, a []driver.NamedValue) (driver.Result, error) {
@@ -139,6 +150,98 @@ func (r *rows) Next(dest []driver.Value) error {
return io.EOF return io.EOF
} }
copy(dest, r.data[r.index]) copy(dest, r.data[r.index])
for i, col := range r.columns {
if (col == "id" || strings.HasSuffix(col, "_id") || col == "created_by") && dest[i] != nil {
dest[i] = fmt.Sprint(dest[i])
}
}
r.index++ r.index++
return nil return nil
} }
// Logical SQL normalization keeps transaction tests focused on their business
// predicates; PostgreSQL scoping and generated SQL are tested independently.
var scopeRE = regexp.MustCompile(`\(SELECT \* FROM "(past_[a-z0-9_]+)" WHERE deleted_at IS NULL\)(?: AS ([a-z0-9_]+))?`)
var paramRE = regexp.MustCompile(`\$[0-9]+`)
var logicalNames = func() map[string]string {
result := map[string]string{}
n := schema.NamingStrategy{TablePrefix: "past_", SingularTable: true}
for name, m := range model.LegacyTables() {
result[n.TableName(reflect.TypeOf(m).Elem().Name())] = name
}
return result
}()
func normalizeSQL(q string) string {
q = scopeRE.ReplaceAllStringFunc(q, func(part string) string {
m := scopeRE.FindStringSubmatch(part)
if m[2] != "" {
return m[2]
}
return logicalNames[m[1]]
})
for physical, logical := range logicalNames {
q = strings.ReplaceAll(q, `"`+physical+`"`, logical)
}
q = strings.ReplaceAll(q, `"`, "")
q = paramRE.ReplaceAllString(q, "?")
q = regexp.MustCompile(`updated_at=\?,?`).ReplaceAllString(q, "")
q = strings.ReplaceAll(q, ", WHERE", " WHERE")
q = regexp.MustCompile(` AND [a-z0-9_]+\.deleted_at IS NULL`).ReplaceAllString(q, "")
q = regexp.MustCompile(`^UPDATE ([a-z0-9_]+) SET deleted_at=\? WHERE `).ReplaceAllString(q, "DELETE FROM $1 WHERE ")
q = regexp.MustCompile(` WHERE \((.*)\)$`).ReplaceAllString(q, " WHERE $1")
return q
}
func compactSQL(q string) string { return strings.Join(strings.Fields(q), "") }
func businessArgs(values []any) []any {
result := make([]any, 0, len(values))
for _, v := range values {
if _, ok := v.(time.Time); !ok {
result = append(result, v)
}
}
return result
}
func equalArgs(a, b []any) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if fmt.Sprint(a[i]) != fmt.Sprint(b[i]) {
return false
}
}
return true
}
// InsertArgs returns values keyed by column for GORM's deterministic column order.
func InsertArgs(q string, args []any) map[string]any {
start := strings.Index(q, "(")
end := strings.Index(q, ") VALUES")
if start < 0 || end < 0 {
return nil
}
tail := q[end+len(") VALUES ("):]
stop := strings.Index(tail, ")")
if stop < 0 {
return nil
}
exprs := strings.Split(tail[:stop], ",")
result := map[string]any{}
i := 0
for n, col := range strings.Split(q[start+1:end], ",") {
col = strings.TrimSpace(col)
if n >= len(exprs) {
break
}
if strings.TrimSpace(exprs[n]) == "?" {
if i < len(args) {
result[col] = args[i]
i++
}
} else {
result[col] = strings.Trim(strings.TrimSpace(exprs[n]), "'")
}
}
return result
}
@@ -0,0 +1,296 @@
package upload
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/minio/minio-go/v7"
"past/internal/config"
"past/internal/model"
"past/internal/store"
)
func pngData(t *testing.T, shade uint8) []byte {
t.Helper()
im := image.NewRGBA(image.Rect(0, 0, 4, 4))
im.Set(0, 0, color.RGBA{R: shade, A: 255})
var b bytes.Buffer
if err := png.Encode(&b, im); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestMinioFileRelations(t *testing.T) {
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set PAST_TEST_POSTGRES_DSN to test real PostgreSQL and MinIO")
}
testConfig := os.Getenv("PAST_TEST_CONFIG")
if testConfig == "" {
testConfig = "../../config.yaml"
}
cfg, err := config.Load(testConfig)
if err != nil {
t.Fatal(err)
}
root, err := store.Open(dsn)
if err != nil {
t.Fatal(err)
}
defer root.Close()
// Initialize the configured production bucket without putting test objects into it.
if _, err := New(cfg.Minio, root, 5*1024*1024, nil); err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("past_files_test_%d", time.Now().UnixNano())
if err := root.ORM.Exec(`CREATE SCHEMA "` + schema + `"`).Error; err != nil {
t.Fatal(err)
}
defer func() {
if err := root.ORM.Exec(`DROP SCHEMA "` + schema + `" CASCADE`).Error; err != nil {
t.Error(err)
}
}()
parsed, _ := url.Parse(dsn)
q := parsed.Query()
q.Set("search_path", schema)
parsed.RawQuery = strings.ReplaceAll(q.Encode(), "%2F", "/")
db, err := store.Open(parsed.String())
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.InitSchema(); err != nil {
t.Fatal(err)
}
cfg.Minio.Bucket = "past-files-test-" + fmt.Sprint(time.Now().UnixNano())
uploader, err := New(cfg.Minio, db, 5*1024*1024, nil)
if err != nil {
t.Fatal(err)
}
defer func() {
for o := range uploader.Client.ListObjects(context.Background(), uploader.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if o.Err != nil {
t.Error(o.Err)
break
}
if err := uploader.Client.RemoveObject(context.Background(), uploader.Bucket, o.Key, minio.RemoveObjectOptions{}); err != nil {
t.Error(err)
}
}
if err := uploader.Client.RemoveBucket(context.Background(), uploader.Bucket); err != nil {
t.Error(err)
}
}()
users := []model.User{{UserId: "520001", Username: "owner", Openid: "openid-owner"}, {UserId: "520002", Username: "partner", Openid: "openid-partner"}, {UserId: "520003", Username: "stranger", Openid: "openid-stranger"}}
for i := range users {
if err := db.ORM.Create(&users[i]).Error; err != nil {
t.Fatal(err)
}
}
cp := model.Couple{User1Id: users[0].Id, User2Id: users[1].Id}
if err := db.ORM.Create(&cp).Error; err != nil {
t.Fatal(err)
}
if err := db.ORM.Model(&model.User{}).Where("id IN ?", []string{users[0].Id, users[1].Id}).Update("couple_id", cp.Id).Error; err != nil {
t.Fatal(err)
}
data := pngData(t, 16)
results := make(chan Result, 8)
for i := 0; i < 8; i++ {
go func(n int) { results <- uploader.save(bytes.NewReader(data), "picture.png", users[n%2].Id) }(i)
}
var first Result
duplicates := 0
for i := 0; i < 8; i++ {
r := <-results
if r.Error != "" {
t.Fatal(r.Error)
}
if i == 0 {
first = r
}
if r.FileId != first.FileId {
t.Fatal("concurrent duplicate uploads returned different IDs")
}
if r.Duplicate {
duplicates++
}
}
if duplicates != 7 {
t.Fatalf("expected seven duplicate hits, got %d", duplicates)
}
var count int64
db.ORM.Model(&model.File{}).Count(&count)
if count != 1 {
t.Fatalf("duplicate metadata rows: %d", count)
}
db.ORM.Model(&model.FileUpload{}).Count(&count)
if count != 2 {
t.Fatalf("upload ownership should be independent: %d", count)
}
hash := md5.Sum(data)
if first.File.Size != int64(len(data)) || first.File.Md5 != hex.EncodeToString(hash[:]) || first.File.ContentType != "image/png" || first.File.Suffix != "png" || !regexp.MustCompile(`^\d{4}/\d{2}/[a-f0-9]{32}\.png$`).MatchString(first.File.ObjectKey) {
t.Fatalf("incorrect metadata: %+v", first.File)
}
objectCount := 0
for obj := range uploader.Client.ListObjects(context.Background(), uploader.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if obj.Err != nil {
t.Fatal(obj.Err)
}
objectCount++
}
if objectCount != 1 {
t.Fatalf("physical deduplication failed: %d objects", objectCount)
}
signed, err := uploader.PresignedURL(context.Background(), first.FileId)
if err != nil {
t.Fatal(err)
}
response, err := http.Get(signed)
if err != nil {
t.Fatal("signed download request failed")
}
download, _ := io.ReadAll(response.Body)
response.Body.Close()
if response.StatusCode != 200 || !bytes.Equal(download, data) {
t.Fatalf("signed object bytes differ: status %d", response.StatusCode)
}
second := uploader.save(bytes.NewReader(pngData(t, 32)), "second.png", users[0].Id)
if second.Error != "" {
t.Fatal(second.Error)
}
actor := db.WithActor(users[0].Id, false)
imageIds, _ := json.Marshal([]string{first.FileId, second.FileId, first.FileId})
diaryId, err := actor.Insert("INSERT INTO diaries (couple_id,title,content,diary_date,images,created_by) VALUES (?,?,?,?,?,?)", cp.Id, "ordered", "text", "2026-11-01", string(imageIds), users[0].Id)
if err != nil {
t.Fatal(err)
}
var refs []model.RecordFile
if err := db.ORM.Where("owner_id=?", diaryId).Order("position").Find(&refs).Error; err != nil {
t.Fatal(err)
}
if len(refs) != 3 || refs[0].FileId != first.FileId || refs[1].FileId != second.FileId || refs[2].FileId != first.FileId {
t.Fatalf("ordered one-to-many references corrupted: %+v", refs)
}
// Replacement clears old links atomically, retaining unrelated owners and object metadata.
replacement, _ := json.Marshal([]string{second.FileId})
if _, err := actor.Exec("UPDATE diaries SET images=? WHERE id=?", string(replacement), diaryId); err != nil {
t.Fatal(err)
}
db.ORM.Model(&model.RecordFile{}).Where("owner_id=?", diaryId).Count(&count)
if count != 1 {
t.Fatalf("old links still active: %d", count)
}
if _, err := actor.Exec("UPDATE users SET avatar=? WHERE id=?", first.FileId, users[0].Id); err != nil {
t.Fatal(err)
}
var owner model.User
db.ORM.First(&owner, "id=?", users[0].Id)
if owner.AvatarFileId == nil || *owner.AvatarFileId != first.FileId || owner.Avatar != first.FileId {
t.Fatal("one-to-one avatar reference corrupted")
}
row, err := actor.QueryOne("SELECT avatar,avatar_file_id FROM users WHERE id=?", users[0].Id)
if err != nil || row.Str("avatar") != store.FileAccessPath(first.FileId) {
t.Fatalf("file ID response resolution failed: %v %v", row, err)
}
if _, err := db.WithActor(users[2].Id, false).Exec("UPDATE users SET avatar=? WHERE id=?", first.FileId, users[2].Id); err == nil {
t.Fatal("stranger linked file without upload permission")
}
if _, err := actor.Exec("UPDATE diaries SET images=?,title=? WHERE id=?", `["00000000-0000-0000-0000-000000000001"]`, "must roll back", diaryId); err == nil {
t.Fatal("missing file was accepted")
}
var persisted model.Diary
db.ORM.First(&persisted, "id=?", diaryId)
if persisted.Title != "ordered" {
t.Fatal("failed reference replacement partially committed parent")
}
if _, err := actor.Exec("DELETE FROM diaries WHERE id=?", diaryId); err != nil {
t.Fatal(err)
}
retainedURL, err := uploader.PresignedURL(context.Background(), first.FileId)
if err != nil {
t.Fatal(err)
}
retainedResponse, err := http.Get(retainedURL)
if err != nil {
t.Fatal(err)
}
retainedBytes, _ := io.ReadAll(retainedResponse.Body)
retainedResponse.Body.Close()
if retainedResponse.StatusCode != 200 || !bytes.Equal(retainedBytes, data) {
t.Fatal("deleting one reference changed the shared object")
}
if result := uploader.save(strings.NewReader("not an image"), "fake.png", users[0].Id); result.Error == "" {
t.Fatal("fake image was uploaded")
}
owners, _ := store.UploadOwners(db.ORM, users[0].Id)
used, err := store.UsedFileBytes(db.ORM, owners)
if err != nil || used != first.File.Size+second.File.Size {
t.Fatalf("deduplicated quota mismatch: %d %v", used, err)
}
// Different content uploaded concurrently must respect one shared quota lock.
if err := db.ORM.Model(&model.User{}).Where("id=?", users[2].Id).Update("storage_quota_mb", 1).Error; err != nil {
t.Fatal(err)
}
type quotaResult struct {
Result Result
Body []byte
}
quotaResults := make(chan quotaResult, 2)
for _, shade := range []uint8{64, 128} {
body := pngData(t, shade)
body = append(body, make([]byte, 600*1024-len(body))...)
go func(data []byte) {
quotaResults <- quotaResult{uploader.save(bytes.NewReader(data), "quota.png", users[2].Id), data}
}(body)
}
successful, blocked := 0, 0
var acceptedBody []byte
for i := 0; i < 2; i++ {
result := <-quotaResults
if result.Result.Error == "" {
successful++
acceptedBody = result.Body
} else if strings.Contains(result.Result.Error, "存储空间不足") {
if result.Result.Code != ErrStorageQuotaExceeded {
t.Fatal("quota error lost the existing client error code")
}
blocked++
} else {
t.Fatal(result.Result.Error)
}
}
if successful != 1 || blocked != 1 {
t.Fatalf("concurrent quota was bypassed: success=%d blocked=%d", successful, blocked)
}
if duplicate := uploader.save(bytes.NewReader(acceptedBody), "quota-again.png", users[2].Id); duplicate.Error != "" || !duplicate.Duplicate {
t.Fatalf("duplicate upload consumed quota twice: %+v", duplicate)
}
if err := db.ORM.Model(&model.User{}).Where("id=?", users[2].Id).Update("storage_quota_mb", 0).Error; err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO settings (key,value) VALUES ('storage_free_mb','0') ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value=EXCLUDED.value"); err != nil {
t.Fatal(err)
}
if blocked := uploader.save(bytes.NewReader(pngData(t, 192)), "zero-quota.png", users[2].Id); blocked.Code != ErrStorageQuotaExceeded {
t.Fatal("configured zero free quota was ignored")
}
t.Logf("real MinIO: concurrent MD5 deduplication, metadata, signed byte download, ordered image links, avatar FK, permissions, rollback and shared retention verified")
}
+281 -114
View File
@@ -1,53 +1,116 @@
// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。 // Package upload validates uploads, deduplicates content and stores objects in MinIO.
package upload package upload
import ( import (
"bytes" "bytes"
"context"
"crypto/md5"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"past/internal/config"
"past/internal/model"
"past/internal/store"
) )
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量) // 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
const ( const (
ErrNoFileReceived = 1001 ErrNoFileReceived = 1001
ErrFileFieldMissing = 1002 ErrFileFieldMissing = 1002
ErrHandleFailed = 1003 ErrHandleFailed = 1003
ErrRawBodySave = 1004 ErrRawBodySave = 1004
ErrAlbumNotFound = 1005 ErrAlbumNotFound = 1005
ErrNotLoggedIn = 1006 ErrNotLoggedIn = 1006
ErrNotBound = 1007 ErrNotBound = 1007
ErrMethodNotAllowed = 1008 ErrMethodNotAllowed = 1008
ErrPartnerInvalid = 1009 ErrPartnerInvalid = 1009
ErrFileTooLarge = 1010 ErrFileTooLarge = 1010
ErrStorageQuotaExceeded = 10001
) )
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true} var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
// ImageChecker 图片内容安全检测(由 wechat 包实现)
type ImageChecker interface { type ImageChecker interface {
CheckImage(filePath, openid string) (bool, string) CheckImage(string, string) (bool, string)
} }
type Uploader struct { type Uploader struct {
// Dir 上传根目录(UPLOAD_DIR) HTTPClient *http.Client
Dir string MaxSize int64
MaxSize int64 Checker ImageChecker
Checker ImageChecker DB *store.DB
Client *minio.Client
Bucket string
Endpoint string
}
type Result struct {
Path string `json:"path"`
FileId string `json:"file_id"`
File *model.File `json:"file,omitempty"`
Duplicate bool `json:"duplicate"`
Error string `json:"error,omitempty"`
Code int `json:"code,omitempty"`
} }
// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...] func New(cfg config.MinioConfig, db *store.DB, maxSize int64, checker ImageChecker) (*Uploader, error) {
type Result struct { endpoint := cfg.Endpoint
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg if !strings.Contains(endpoint, "://") {
Error string scheme := "http"
if cfg.UseSSL {
scheme = "https"
}
endpoint = scheme + "://" + endpoint
}
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || strings.Trim(parsed.Path, "/") != "" {
return nil, fmt.Errorf("MinIO endpoint 必须是有效的服务地址")
}
secure := parsed.Scheme == "https"
if parsed.Scheme != "http" && !secure {
return nil, fmt.Errorf("MinIO endpoint scheme 无效")
}
if cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.Bucket == "" {
return nil, fmt.Errorf("MinIO 配置不完整")
}
transport, err := minio.DefaultTransport(secure)
if err != nil {
return nil, err
}
client, err := minio.New(parsed.Host, &minio.Options{Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: secure, Transport: transport, BucketLookup: minio.BucketLookupPath, Region: "us-east-1"})
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil && minio.ToErrorResponse(err).Code != "AccessDenied" {
return nil, fmt.Errorf("检查 MinIO bucket 失败: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
code := minio.ToErrorResponse(err).Code
if code != "BucketAlreadyOwnedByYou" && code != "BucketAlreadyExists" {
return nil, err
}
}
}
return &Uploader{HTTPClient: &http.Client{Transport: transport, Timeout: 2 * time.Minute}, DB: db, Client: client, Bucket: cfg.Bucket, Endpoint: strings.TrimSuffix(endpoint, "/"), MaxSize: maxSize, Checker: checker}, nil
} }
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId} // AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
@@ -62,8 +125,8 @@ func CouplePath(spaceID, typ string) string {
} }
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type} // UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
func UserFilePath(userID int64, typ string) string { func UserFilePath(userID string, typ string) string {
p := fmt.Sprintf("users/%d", userID) p := fmt.Sprintf("users/%s", userID)
if typ != "" { if typ != "" {
p += "/" + typ p += "/" + typ
} }
@@ -82,113 +145,217 @@ func Ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
} }
// Handle 对应 handleUpload($file, $subDir, $prefixPath) // The directory arguments remain source-compatible; object keys always use date folders.
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result { func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefix string) Result {
return u.HandleFor(fh, "")
}
func (u *Uploader) HandleFor(fh *multipart.FileHeader, userId string) Result {
if fh == nil { if fh == nil {
return Result{Error: "未接收到文件"} return Result{Error: "未接收到文件"}
} }
f, err := fh.Open() f, err := fh.Open()
if err != nil { if err != nil {
return Result{Error: "未接收到文件"} return Result{Error: "文件读取失败"}
} }
defer f.Close() defer f.Close()
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath) return u.save(f, fh.Filename, userId)
} }
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefix string) Result {
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源 return u.save(f, filename, "")
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result { }
if size > u.MaxSize { func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefix string) Result {
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)} return u.save(bytes.NewReader(data), filename, "")
}
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefix string) Result {
return u.SaveRawFor(data, ext, "")
}
func (u *Uploader) SaveRawFor(data []byte, ext, userId string) Result {
return u.save(bytes.NewReader(data), "upload."+ext, userId)
}
func (u *Uploader) save(src io.Reader, filename, userId string) Result {
if u.Client == nil || u.DB == nil {
return Result{Error: "MinIO 文件服务未初始化"}
}
if u.MaxSize <= 0 {
return Result{Error: "上传大小限制无效"}
} }
ext := Ext(filename) ext := Ext(filename)
if !allowedExts[ext] { if !allowedExts[ext] {
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"} return Result{Error: "不支持的文件类型"}
} }
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝 tmp, err := os.CreateTemp("", "past-upload-*."+ext)
head := make([]byte, 512)
n, _ := io.ReadFull(f, head)
// finfo 只返回主类型(不带 "; charset=...")
mime := http.DetectContentType(head[:n])
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" {
return Result{Error: "文件MIME类型不匹配: " + mime}
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Result{Error: "文件读取失败"}
}
return u.save(f, "", ext, subDir, prefixPath)
}
// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验)
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result {
return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath)
}
// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。
// namePrefix 为文件名前缀(如 "bg_")。
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result {
return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath)
}
func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result {
rel := "uploads/"
dir := u.Dir
if prefixPath != "" {
rel += prefixPath + "/"
dir = filepath.Join(dir, filepath.FromSlash(prefixPath))
}
if subDir != "" {
rel += subDir + "/"
dir = filepath.Join(dir, filepath.FromSlash(subDir))
}
if err := os.MkdirAll(dir, 0755); err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
name := namePrefix + NewFilename(ext)
dest := filepath.Join(dir, name)
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { if err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()} return Result{Error: "创建临时文件失败"}
} }
if _, err := io.Copy(out, src); err != nil { defer os.Remove(tmp.Name())
out.Close() defer tmp.Close()
os.Remove(dest) md5Hash, shaHash := md5.New(), sha256.New()
return Result{Error: "文件保存失败。系统错误: " + err.Error()} size, err := io.Copy(io.MultiWriter(tmp, md5Hash, shaHash), io.LimitReader(src, u.MaxSize+1))
if err != nil {
return Result{Error: "读取文件失败"}
} }
out.Close() if size == 0 || size > u.MaxSize {
slog.Info("upload saved", "path", rel+name) return Result{Error: "文件为空或超过大小限制"}
}
head := make([]byte, 512)
tmp.Seek(0, io.SeekStart)
n, _ := tmp.Read(head)
contentType := http.DetectContentType(head[:n])
suffixes := map[string]string{"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp"}
suffix, ok := suffixes[contentType]
if !ok {
return Result{Error: "文件内容不是支持的图片类型"}
}
digest, sha := hex.EncodeToString(md5Hash.Sum(nil)), hex.EncodeToString(shaHash.Sum(nil))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var file model.File
duplicate := false
err = u.DB.ORM.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if userId != "" {
var owner model.User
if err := tx.Select("id").Clauses(clause.Locking{Strength: "KEY SHARE"}).First(&owner, "id=?", userId).Error; err != nil {
return fmt.Errorf("账号不存在或已注销")
}
owners, err := store.UploadOwners(tx, userId)
if err != nil {
return err
}
sort.Strings(owners)
if len(owners) > 0 {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", "file-quota:"+owners[0]).Error; err != nil {
return err
}
}
}
if u.Checker != nil { if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", u.Bucket+":"+digest).Error; err != nil {
if pass, msg := u.Checker.CheckImage(dest, ""); !pass { return err
os.Remove(dest) }
return Result{Error: msg} existing := tx.Unscoped().Where("bucket=? AND md5=?", u.Bucket, digest).Find(&file)
if existing.Error != nil {
return existing.Error
}
if existing.RowsAffected > 0 {
if file.Size != size || file.Sha256 != sha {
return fmt.Errorf("文件 MD5 校验冲突,已拒绝复用")
}
if err := store.CheckFileQuota(tx, userId, file); err != nil {
return err
}
if file.DeletedAt.Valid {
if err := tx.Unscoped().Model(&file).Update("deleted_at", nil).Error; err != nil {
return err
}
file.DeletedAt = gorm.DeletedAt{}
}
duplicate = true
return store.GrantFile(tx, userId, file.Id)
}
originalName := strings.ToValidUTF8(filepath.Base(strings.ReplaceAll(filename, "\\", "/")), "�")
originalName = strings.Map(func(r rune) rune {
if r == 0 || r == '\r' || r == '\n' {
return -1
}
return r
}, originalName)
if runes := []rune(originalName); len(runes) > 255 {
originalName = string(runes[:255])
}
file = model.File{Size: size, Suffix: suffix, ContentType: contentType, Md5: digest, Sha256: sha, Bucket: u.Bucket, OriginalName: originalName}
if err := store.CheckFileQuota(tx, userId, file); err != nil {
return err
}
if u.Checker != nil {
if pass, msg := u.Checker.CheckImage(tmp.Name(), ""); !pass {
return fmt.Errorf("%s", msg)
}
}
loc, _ := time.LoadLocation("Asia/Shanghai")
file.ObjectKey = time.Now().In(loc).Format("2006/01/") + digest + "." + suffix
file.Url = u.Endpoint + "/" + u.Bucket + "/" + file.ObjectKey
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return err
}
if _, err := u.Client.PutObject(ctx, u.Bucket, file.ObjectKey, tmp, size, minio.PutObjectOptions{ContentType: contentType, SendContentMd5: true, UserMetadata: map[string]string{"md5": digest, "sha256": sha}}); err != nil {
return fmt.Errorf("上传 MinIO 失败: %w", err)
}
if err := tx.Create(&file).Error; err != nil {
return err
}
return store.GrantFile(tx, userId, file.Id)
})
if err != nil {
code := 0
if errors.Is(err, store.ErrUploadQuota) {
code = ErrStorageQuotaExceeded
}
return Result{Error: err.Error(), Code: code}
}
return Result{Path: store.FileAccessPath(file.Id), FileId: file.Id, File: &file, Duplicate: duplicate}
}
func (u *Uploader) PresignedURL(ctx context.Context, id string) (string, error) {
if !store.ValidUUID(id) {
return "", fmt.Errorf("文件 ID 无效")
}
var file model.File
if err := u.DB.ORM.WithContext(ctx).First(&file, "id=?", id).Error; err != nil {
return "", err
}
signed, err := u.Client.PresignedGetObject(ctx, file.Bucket, file.ObjectKey, 15*time.Minute, url.Values{"response-content-type": []string{file.ContentType}})
if err != nil {
return "", err
}
return signed.String(), nil
}
// Removing one business reference must never delete a shared physical object.
func (u *Uploader) Remove(path string) {}
// CleanupDeletedFiles keeps metadata and the queue until MinIO confirms removal.
func (u *Uploader) CleanupDeletedFiles() error {
var jobs []model.FileDeletion
if err := u.DB.ORM.Limit(50).Find(&jobs).Error; err != nil {
return err
}
for _, job := range jobs {
err := u.DB.ORM.Transaction(func(tx *gorm.DB) error {
var file model.File
if err := tx.Unscoped().First(&file, "id=?", job.FileId).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Unscoped().Delete(&job).Error
} else if err != nil {
return err
}
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", file.Bucket+":"+file.Md5).Error; err != nil {
return err
}
if err := tx.Unscoped().First(&file, "id=?", job.FileId).Error; err != nil {
return err
}
if !file.DeletedAt.Valid {
return tx.Unscoped().Delete(&job).Error
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := u.Client.RemoveObject(ctx, file.Bucket, file.ObjectKey, minio.RemoveObjectOptions{}); err != nil {
return err
}
if err := tx.Unscoped().Delete(&file).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&job).Error
})
if err != nil {
return err
} }
} }
return Result{Path: rel + name} return nil
} }
func (u *Uploader) RunFileCleanupWorker() {
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。 for {
func (u *Uploader) Remove(relPath string) { if err := u.CleanupDeletedFiles(); err != nil {
if relPath == "" || strings.Contains(relPath, "..") { zap.S().Errorw("注销文件清理失败,将自动重试", "err", err)
return }
time.Sleep(30 * time.Second)
} }
p := strings.TrimPrefix(relPath, "uploads/")
if p == relPath {
return
}
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
}
// Abs 返回相对路径对应的绝对路径
func (u *Uploader) Abs(relPath string) string {
p := strings.TrimPrefix(relPath, "uploads/")
return filepath.Join(u.Dir, filepath.FromSlash(p))
}
// Exists 文件是否存在
func (u *Uploader) Exists(relPath string) bool {
st, err := os.Stat(u.Abs(relPath))
return err == nil && !st.IsDir()
} }
-138
View File
@@ -1,138 +0,0 @@
// Package virtualpay 实现微信小程序虚拟支付(米大师虚拟支付2.0)的道具直购签名与参数构造。
// 参考微信文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/midas-payment/
package virtualpay
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"sort"
"strings"
"time"
)
// Client 虚拟支付客户端配置。AppKey 由 New 根据环境自动选择(沙箱/正式)。
type Client struct {
OfferID string // 支付应用 ID(OfferID)
AppKey string // 当前环境使用的 AppKey
Env int // 0-正式环境 1-沙箱环境
}
// New 构造虚拟支付客户端。env=1 使用沙箱 AppKey,否则使用正式 AppKey。
func New(offerID, appKey, sandboxKey string, env int) *Client {
key := appKey
if env == 1 {
key = sandboxKey
}
return &Client{OfferID: offerID, AppKey: key, Env: env}
}
// SignData 道具直购(wx.requestVirtualPayment)的 signData 结构。
// 字段名与微信虚拟支付2.0保持一致;goodsPrice 单位为「分」。
type SignData struct {
OfferID string `json:"offerId"`
BuyQuantity int `json:"buyQuantity"`
Env int `json:"env"`
CurrencyType string `json:"currencyType"`
ProductID string `json:"productId"`
GoodsPrice int `json:"goodsPrice"`
OutTradeNo string `json:"outTradeNo"`
Attach string `json:"attach,omitempty"`
}
// BuildSignData 构造 signData JSON 字符串。
func (c *Client) BuildSignData(productID string, goodsPrice int, outTradeNo, attach string) (string, error) {
sd := SignData{
OfferID: c.OfferID,
BuyQuantity: 1,
Env: c.Env,
CurrencyType: "CNY",
ProductID: productID,
GoodsPrice: goodsPrice,
OutTradeNo: outTradeNo,
Attach: attach,
}
b, err := json.Marshal(sd)
if err != nil {
return "", err
}
return string(b), nil
}
// PaySig 支付签名:HMAC-SHA256(appKey, "requestVirtualPayment&" + signData) 的十六进制小写。
func (c *Client) PaySig(signData string) string {
return hmacSHA256Hex(c.AppKey, "requestVirtualPayment&"+signData)
}
// SessionSignature 会话签名:HMAC-SHA256(sessionKey, signData) 的十六进制小写。
func SessionSignature(sessionKey, signData string) string {
return hmacSHA256Hex(sessionKey, signData)
}
func hmacSHA256Hex(key, data string) string {
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
// QueryOrderResult 服务端 query_order 接口的响应。
type QueryOrderResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Order struct {
OrderID string `json:"order_id"`
Status int `json:"status"` // 2=已支付待发货 3=发货中 4=已发货
PaidFee int `json:"paid_fee"`
PaidTime int64 `json:"paid_time"`
WxOrderID string `json:"wx_order_id"`
} `json:"order"`
}
// QueryOrder 服务端主动查单(现金单)。用于发货推送丢失时兜底补发货。
// 签名:pay_sig = HMAC-SHA256(AppKey, "/xpay/query_order&" + postBody)。
func (c *Client) QueryOrder(accessToken, openid, orderID string) (*QueryOrderResult, error) {
body, err := json.Marshal(map[string]any{
"openid": openid,
"env": c.Env,
"order_id": orderID,
})
if err != nil {
return nil, err
}
uri := "/xpay/query_order"
paySig := hmacSHA256Hex(c.AppKey, uri+"&"+string(body))
u := "https://api.weixin.qq.com/xpay/query_order?access_token=" + accessToken + "&pay_sig=" + paySig
cl := &http.Client{Timeout: 10 * time.Second}
resp, err := cl.Post(u, "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
var out QueryOrderResult
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// VerifyNotifySignature 校验微信消息推送回调的 signature。
// 微信规则:signature = sha1( sort(token, timestamp, nonce) )。
func VerifyNotifySignature(token, timestamp, nonce, signature string) bool {
if signature == "" || token == "" {
return false
}
parts := []string{token, timestamp, nonce}
sort.Strings(parts)
sum := sha1.Sum([]byte(strings.Join(parts, "")))
return hex.EncodeToString(sum[:]) == strings.ToLower(signature)
}
+55
View File
@@ -0,0 +1,55 @@
package wechat
import (
"strings"
"time"
)
// CheckinMessage matches template 15198: 留言内容、打卡时间、温馨提示。
// Persist the note on the activity so immediate sends and retries use the same content.
func CheckinMessage(title, note, timestamp string) map[string]string {
content := strings.TrimSpace(note)
if content == "" {
content = title
if !strings.HasPrefix(content, "打卡「") {
content = "打卡「" + content + "」"
}
}
// Database rows may use RFC3339; display all check-in times in China time.
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, timestamp, time.FixedZone("CST", 8*3600)); err == nil {
timestamp = t.In(time.FixedZone("CST", 8*3600)).Format("2006年01月02日 15:04")
break
}
}
return normalizeSubscribeData(map[string]string{
"thing1": content,
"time2": timestamp,
"thing3": "TA已完成打卡,点击看看吧",
})
}
// ReminderMessage adapts all activity notifications to the shared three-field
// template. Legacy callers keep their content and landing page.
func ReminderMessage(data map[string]string) map[string]string {
if data["time2"] != "" {
return normalizeSubscribeData(map[string]string{"thing1": data["thing1"], "time2": data["time2"], "thing3": data["thing3"]})
}
content := data["thing2"]
if strings.TrimSpace(content) == "" {
content = data["thing1"]
}
if strings.TrimSpace(content) == "" {
content = "有新的记录"
}
stamp := data["event_time"]
if stamp == "" {
stamp = time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05")
}
out := CheckinMessage("", content, stamp)
out["thing3"] = data["thing4"]
if strings.TrimSpace(out["thing3"]) == "" {
out["thing3"] = "TA有新的动态,点击查看"
}
return normalizeSubscribeData(out)
}
+50
View File
@@ -0,0 +1,50 @@
package wechat
import (
"context"
"database/sql/driver"
"encoding/json"
"io"
"net/http"
"past/internal/store"
"past/internal/testdb"
"strings"
"testing"
"time"
)
func TestCheckinTemplatePayload(t *testing.T) {
const template = "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU"
db, _ := testdb.Open(t, []testdb.Step{
{Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"partner"}}},
{Match: "SELECT id FROM subscribe_templates", Args: []any{int64(2), "notify", template}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(1)}}},
{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}},
{Match: "INSERT INTO subscribe_message_logs"},
})
c := New("app", "secret", "old-template", testRedis(t), "checkin:", store.WrapSQL(db))
c.NotifyTemplateID = template
c.Redis.Set(context.Background(), c.tokenKey(), "cached-token", time.Hour)
c.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
var p struct {
Template string `json:"template_id"`
Page string `json:"page"`
Data map[string]struct {
Value string `json:"value"`
} `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Fatal(err)
}
if p.Template != template || p.Page != "pages/punch/punch" || len(p.Data) != 3 || p.Data["thing1"].Value != "今天也坚持啦" || p.Data["time2"].Value != "2026年11月03日 20:58" || p.Data["thing3"].Value == "" {
t.Fatalf("wrong payload: %+v", p)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"errcode":0}`))}, nil
})
if !c.SendSubscribeMessage("2", "checkin", CheckinMessage("运动", "今天也坚持啦", "2026-11-03T12:58:00Z"), "pages/punch/punch", "", "") {
t.Fatal("send failed")
}
data := CheckinMessage(strings.Repeat("爱", 30), "", "2026-11-03 20:58:00")
if len([]rune(data["thing1"])) != 20 || data["time2"] != "2026年11月03日 20:58" {
t.Fatalf("invalid fallback: %v", data)
}
}
+23 -17
View File
@@ -1,17 +1,17 @@
package wechat package wechat
import ( import (
"context"
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"past/internal/store" "past/internal/store"
"past/internal/testdb" "past/internal/testdb"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
type subscribeTransport func(*http.Request) (*http.Response, error) type subscribeTransport func(*http.Request) (*http.Response, error)
@@ -41,16 +41,18 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
if tt.subscribed { if tt.subscribed {
auth = [][]driver.Value{{int64(1)}} auth = [][]driver.Value{{int64(1)}}
} }
steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth}) steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify", tt.template}, Columns: []string{"id"}, Rows: auth})
} }
meta := []testdb.Step{{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(5)}}}, {Match: "SELECT action_type FROM activity_log", Columns: []string{"action_type"}, Rows: [][]driver.Value{{"diary_create"}}}} meta := []testdb.Step{{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(5)}}}, {Match: "SELECT action_type FROM activity_log", Columns: []string{"action_type"}, Rows: [][]driver.Value{{"diary_create"}}}}
steps = append(steps, meta...) steps = append(steps, meta...)
steps = append(steps, testdb.Step{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error { steps = append(steps, testdb.Step{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error {
if args[4] != tt.openid || args[5] != "diary_create" || args[6] != "new diary" { v := testdb.InsertArgs(q, args)
args = []any{v["activity_id"], v["couple_id"], v["user_id"], v["to_user_id"], v["to_openid"], v["action_type"], v["message_title"], v["message_content"], v["template_type"], v["template_id"], v["page"], v["status"], v["error_code"], v["http_status"], v["error_msg"], v["attempts"], v["duration_ms"], v["sent_at"]}
if args[4] != tt.openid || args[5] != "diary_create" || args[6] != strings.Repeat("爱", 20) {
t.Errorf("wrong recipient/event: %v", args) t.Errorf("wrong recipient/event: %v", args)
return nil return nil
} }
if args[7] != strings.Repeat("爱", 20) { if !strings.Contains(fmt.Sprint(args[7]), strings.Repeat("爱", 20)) {
t.Errorf("log must record normalized content: %v", args[7]) t.Errorf("log must record normalized content: %v", args[7])
} }
status := "failed" status := "failed"
@@ -78,23 +80,26 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
steps = append(steps, meta...) steps = append(steps, meta...)
steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"}) steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"})
db, _ := testdb.Open(t, steps) db, _ := testdb.Open(t, steps)
client := New("app", "secret", tt.template, t.TempDir(), &store.DB{DB: db}) client := New("app", "secret", tt.template, testRedis(t), "test:", store.WrapSQL(db))
os.MkdirAll(filepath.Dir(client.cacheFile()), 0755) client.Redis.Set(context.Background(), client.tokenKey(), "cached-token", time.Hour)
os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600)
calls := 0 calls := 0
client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) { client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
calls++ calls++
var payload map[string]any var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload) json.NewDecoder(r.Body).Decode(&payload)
fields := payload["data"].(map[string]any)
if len(fields) != 3 || fields["time2"] == nil || fields["thing3"] == nil {
t.Fatalf("legacy fields leaked into shared template: %v", fields)
}
if payload["touser"] != tt.openid { if payload["touser"] != tt.openid {
t.Fatal("wrong WeChat recipient") t.Fatal("wrong WeChat recipient")
} }
if payload["data"].(map[string]any)["thing2"].(map[string]any)["value"] != strings.Repeat("爱", 20) { if payload["data"].(map[string]any)["thing1"].(map[string]any)["value"] != strings.Repeat("爱", 20) {
t.Fatal("overlong content sent to WeChat") t.Fatal("overlong content sent to WeChat")
} }
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(tt.response))}, nil return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(tt.response))}, nil
}) })
success := client.SendSubscribeMessage(2, "notify", map[string]string{"thing1": "new diary", "thing2": strings.Repeat("爱", 50)}, "", 10, 1) success := client.SendSubscribeMessage("2", "notify", map[string]string{"thing1": "new diary", "thing2": strings.Repeat("爱", 50)}, "", "10", "1")
if success != tt.success || calls != tt.requests { if success != tt.success || calls != tt.requests {
t.Fatalf("success=%v requests=%d", success, calls) t.Fatalf("success=%v requests=%d", success, calls)
} }
@@ -107,25 +112,26 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
{Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"recipient"}}}, {Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"recipient"}}},
{Match: "SELECT id FROM subscribe_templates", Columns: []string{"id"}}, {Match: "SELECT id FROM subscribe_templates", Columns: []string{"id"}},
{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}}, {Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}},
{Match: "INSERT INTO subscribe_message_logs", Check: func(_ string, args []any) error { {Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error {
if args[0] != int64(0) || args[4] != "recipient" || args[11] != "failed" { v := testdb.InsertArgs(q, args)
if v["activity_id"] != nil || v["to_openid"] != "recipient" || v["status"] != "failed" {
t.Error("missing zero-activity log") t.Error("missing zero-activity log")
return nil
} }
return nil return nil
}}, }},
}) })
client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db}) client := New("app", "secret", "template", testRedis(t), "test:", store.WrapSQL(db))
if client.SendSubscribeMessage(2, "notify", nil, "", 0, 0) { if client.SendSubscribeMessage("2", "notify", nil, "", "", "") {
t.Fatal("unsubscribed send succeeded") t.Fatal("unsubscribed send succeeded")
} }
} }
func TestSuccessfulEventIsNotSentAgain(t *testing.T) { func TestSuccessfulEventIsNotSentAgain(t *testing.T) {
db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}}) db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}})
client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db}) client := New("app", "secret", "template", testRedis(t), "test:", store.WrapSQL(db))
client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil }) client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil })
if !client.SendSubscribeMessage(2, "notify", nil, "", 10, 1) { if !client.SendSubscribeMessage("2", "notify", nil, "", "10", "1") {
t.Fatal("completed message must be treated as success") t.Fatal("completed message must be treated as success")
} }
} }
+120
View File
@@ -0,0 +1,120 @@
package wechat
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// AppID is part of the key; environments can use different key prefixes or DBs.
func (c *Client) tokenKey() string { return c.KeyPrefix + "wechat:{" + c.AppID + "}:access_token" }
var compareDelete = redis.NewScript(`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`)
// AccessToken shares cached tokens and a refresh lock across processes.
// Redis failures never fall back to the old plaintext file or repeated refreshes.
func (c *Client) AccessToken() string {
c.mu.Lock()
defer c.mu.Unlock()
if c.Redis == nil {
zap.L().Error("微信 token 缓存未初始化")
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
key := c.tokenKey()
lockKey, owner := key+":lock", uuid.NewString()
for {
token, err := c.Redis.Get(ctx, key).Result()
if err == nil && token != "" {
return token
}
if err != nil && !errors.Is(err, redis.Nil) {
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
return ""
}
locked, err := c.Redis.SetNX(ctx, lockKey, owner, 30*time.Second).Result()
if err != nil {
zap.L().Error("获取微信 token 刷新锁失败", zap.Error(err))
return ""
}
if locked {
defer func() {
unlockCtx, done := context.WithTimeout(context.Background(), 2*time.Second)
defer done()
if err := compareDelete.Run(unlockCtx, c.Redis, []string{lockKey}, owner).Err(); err != nil {
zap.L().Warn("释放微信 token 刷新锁失败", zap.Error(err))
}
}()
// Another process may have filled the cache between our GET and SET NX.
token, err = c.Redis.Get(ctx, key).Result()
if err == nil && token != "" {
return token
}
if err != nil && !errors.Is(err, redis.Nil) {
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
return ""
}
return c.fetchToken(ctx, key)
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
zap.L().Warn("等待微信 token 刷新超时")
return ""
case <-timer.C:
}
}
}
func (c *Client) fetchToken(ctx context.Context, key string) string {
endpoint := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ""
}
resp, err := c.http.Do(req)
if err != nil {
zap.L().Error("微信 token 请求失败")
return ""
} // Do not log the URL containing AppSecret.
defer resp.Body.Close()
var data struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
Errcode int `json:"errcode"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || resp.StatusCode != http.StatusOK || data.Errcode != 0 || data.AccessToken == "" || data.ExpiresIn <= 0 {
zap.L().Error("微信 token 返回无效", zap.Int("http_status", resp.StatusCode), zap.Int("errcode", data.Errcode))
return ""
}
ttl := time.Duration(data.ExpiresIn) * time.Second
// Refresh early without making short-lived tokens immediately expire.
margin := min(ttl/10, 5*time.Minute)
ttl -= margin
if err := c.Redis.Set(ctx, key, data.AccessToken, ttl).Err(); err != nil {
zap.L().Error("写入微信 token 缓存失败", zap.Error(err))
return ""
}
return data.AccessToken
}
// Invalidate only the token rejected by WeChat, preserving a concurrent refresh.
func (c *Client) clearTokenCache(token string) {
if c.Redis == nil || token == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := compareDelete.Run(ctx, c.Redis, []string{c.tokenKey()}, token).Err(); err != nil {
zap.L().Warn("清理失效微信 token 失败", zap.Error(err))
}
}
+107
View File
@@ -0,0 +1,107 @@
package wechat
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func testRedis(t *testing.T) *redis.Client {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr(), MaxRetries: -1})
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestAccessTokenSharedRefreshExpiryAndInvalidation(t *testing.T) {
server := miniredis.RunT(t)
cache := redis.NewClient(&redis.Options{Addr: server.Addr()})
defer cache.Close()
var requests atomic.Int32
transport := subscribeTransport(func(r *http.Request) (*http.Response, error) {
n := requests.Add(1)
if r.URL.Query().Get("appid") != "app" || r.URL.Query().Get("secret") != "secret" {
t.Error("missing credentials")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"access_token":"token-%d","expires_in":7200}`, n)))}, nil
})
clients := make([]*Client, 12)
var wg sync.WaitGroup
for i := range clients {
clients[i] = New("app", "secret", "template", cache, "past-test:", nil)
clients[i].http.Transport = transport
wg.Add(1)
go func(c *Client) {
defer wg.Done()
if c.AccessToken() != "token-1" {
t.Error("concurrent refresh mismatch")
}
}(clients[i])
}
wg.Wait()
c := clients[0]
if requests.Load() != 1 {
t.Fatalf("expected one refresh, got %d", requests.Load())
}
if ttl := server.TTL(c.tokenKey()); ttl != 6900*time.Second {
t.Fatalf("wrong token TTL: %s", ttl)
}
if server.Exists(c.tokenKey() + ":lock") {
t.Fatal("refresh lock leaked")
}
// A late rejection must not remove a newer token.
c.clearTokenCache("obsolete-token")
if c.AccessToken() != "token-1" {
t.Fatal("new token deleted by stale caller")
}
c.clearTokenCache("token-1")
if c.AccessToken() != "token-2" {
t.Fatal("invalid token was reused")
}
server.FastForward(6901 * time.Second)
if c.AccessToken() != "token-3" {
t.Fatal("expired token was reused")
}
other := New("different-app", "secret", "", cache, "past-test:", nil)
if c.tokenKey() == other.tokenKey() {
t.Fatal("AppIDs share a cache key")
}
}
func TestAccessTokenFailureDoesNotCacheOrFetchWithoutRedis(t *testing.T) {
for _, response := range []string{`{"errcode":40013}`, `{"access_token":"bad","expires_in":0}`, `not-json`} {
t.Run(response, func(t *testing.T) {
cache := testRedis(t)
c := New("app", "secret", "", cache, "test:", nil)
c.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response))}, nil
})
if c.AccessToken() != "" {
t.Fatal("invalid response accepted")
}
if n := cache.Exists(context.Background(), c.tokenKey(), c.tokenKey()+":lock").Val(); n != 0 {
t.Fatal("invalid token or lock retained")
}
})
}
cache := testRedis(t)
_ = cache.Close()
c := New("app", "secret", "", cache, "test:", nil)
c.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) {
t.Error("WeChat contacted during Redis failure")
return nil, fmt.Errorf("unexpected")
})
if c.AccessToken() != "" {
t.Fatal("Redis failure ignored")
}
}
+42 -62
View File
@@ -5,8 +5,10 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"hash/fnv"
"io" "io"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@@ -23,7 +25,8 @@ type Client struct {
AppID string AppID string
Secret string Secret string
NotifyTemplateID string NotifyTemplateID string
DataDir string // 用于 access_token.cache Redis redis.UniversalClient
KeyPrefix string
DB *store.DB DB *store.DB
http *http.Client http *http.Client
@@ -31,51 +34,16 @@ type Client struct {
sendLocks [64]sync.Mutex sendLocks [64]sync.Mutex
} }
func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client { func New(appID, secret, notifyTemplateID string, cache redis.UniversalClient, prefix string, db *store.DB) *Client {
return &Client{ return &Client{
AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, DataDir: dataDir, DB: db, AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, Redis: cache, KeyPrefix: prefix, DB: db,
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
} }
} }
// Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES // Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES
func (c *Client) Templates() map[string]string { func (c *Client) Templates() map[string]string {
return map[string]string{"notify": c.NotifyTemplateID} return map[string]string{"notify": c.NotifyTemplateID, "checkin": c.NotifyTemplateID}
}
func (c *Client) cacheFile() string { return filepath.Join(c.DataDir, "data", "access_token.cache") }
// AccessToken 获取 access_token;文件缓存 115 分钟(按修改时间判断),与 PHP 一致。
func (c *Client) AccessToken() string {
c.mu.Lock()
defer c.mu.Unlock()
cf := c.cacheFile()
if st, err := os.Stat(cf); err == nil && time.Since(st.ModTime()) < 6900*time.Second {
if b, err := os.ReadFile(cf); err == nil && len(bytes.TrimSpace(b)) > 0 {
return string(bytes.TrimSpace(b))
}
}
u := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
resp, err := c.http.Get(u)
if err != nil {
return ""
}
defer resp.Body.Close()
var data struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.AccessToken == "" {
return ""
}
os.MkdirAll(filepath.Dir(cf), 0755)
os.WriteFile(cf, []byte(data.AccessToken), 0644)
return data.AccessToken
}
func (c *Client) clearTokenCache() {
c.mu.Lock()
os.Remove(c.cacheFile())
c.mu.Unlock()
} }
// postJSON 对应 wechatPost:非 200 返回 nil // postJSON 对应 wechatPost:非 200 返回 nil
@@ -188,22 +156,25 @@ func (c *Client) Jscode2Session(code string) (openid string, sessionKey string,
} }
// SendSubscribeMessage 对应 sendSubscribeMessage()。 // SendSubscribeMessage 对应 sendSubscribeMessage()。
func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool { func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data map[string]string, page string, activityID string, userID string) bool {
if templateType == "checkin" {
templateType = "notify"
}
// 同一事件的即时发送和补发串行,并在发送前重新检查成功记录。 // 同一事件的即时发送和补发串行,并在发送前重新检查成功记录。
if activityID != 0 { if activityID != "" {
lock := &c.sendLocks[uint64(activityID+toUserID)%uint64(len(c.sendLocks))] lock := &c.sendLocks[messageLockHash(activityID+toUserID)%uint64(len(c.sendLocks))]
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID) previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID)
if err != nil { if err != nil {
slog.Error("读取通知去重状态失败", "err", err) zap.S().Errorw("读取通知去重状态失败", "err", err)
return false return false
} }
if previous != nil && previous.Str("status") == "success" { if previous != nil && previous.Str("status") == "success" {
return true return true
} }
} }
data = normalizeSubscribeData(data) data = ReminderMessage(data)
started := time.Now() started := time.Now()
templateID, known := c.Templates()[templateType] templateID, known := c.Templates()[templateType]
status, lastError, openid := "failed", "", "" status, lastError, openid := "failed", "", ""
@@ -214,7 +185,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
} }
defer func() { defer func() {
c.logSubscribeAttempt(activityID, toUserID, userID, templateType, templateID, openid, page, data, status, lastError, errorCode, httpStatus, attempts, started) c.logSubscribeAttempt(activityID, toUserID, userID, templateType, templateID, openid, page, data, status, lastError, errorCode, httpStatus, attempts, started)
if activityID != 0 { if activityID != "" {
c.LogNotifyResult(activityID, toUserID, userID, templateType, status, lastError) c.LogNotifyResult(activityID, toUserID, userID, templateType, status, lastError)
} }
}() }()
@@ -234,7 +205,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
lastError = "用户无openid" lastError = "用户无openid"
return false return false
} }
sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type IN (?, 'checkin') AND template_id = ?", toUserID, templateType, templateID)
if err != nil { if err != nil {
lastError = "读取订阅授权失败" lastError = "读取订阅授权失败"
return false return false
@@ -260,7 +231,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
if tok == "" { if tok == "" {
lastError = "获取access_token失败" lastError = "获取access_token失败"
if attempt < 2 { if attempt < 2 {
c.clearTokenCache() c.clearTokenCache(tok)
} }
continue continue
} }
@@ -274,7 +245,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
lastError += ": " + uerr.Err.Error() lastError += ": " + uerr.Err.Error()
} }
if attempt < 2 { if attempt < 2 {
c.clearTokenCache() c.clearTokenCache(tok)
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
continue continue
@@ -284,7 +255,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
resp.Body.Close() resp.Body.Close()
lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode) lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode)
if attempt < 2 { if attempt < 2 {
c.clearTokenCache() c.clearTokenCache(tok)
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
continue continue
@@ -305,12 +276,11 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
} }
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec) lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 { if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 {
c.clearTokenCache() c.clearTokenCache(tok)
continue continue
} }
// 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录: // 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录:
// 这样 my_subscribe_templates 始终返回“已订阅”,前端不会因此重复弹出订阅授权框, // 登记不代表剩余次数;用户后续点击时仍需调用订阅授权补充次数。
// 满足“用户只需订阅一次、之后不再重复勾选”。发送失败仅记录日志,由前端在操作前静默续订补足次数。
break break
} }
return false return false
@@ -336,9 +306,9 @@ func normalizeSubscribeData(data map[string]string) map[string]string {
} }
// 每次逻辑发送追加一条记录;notify_log 继续负责事件去重。 // 每次逻辑发送追加一条记录;notify_log 继续负责事件去重。
func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templateType, templateID, openid, page string, data map[string]string, status, errorMsg string, errorCode, httpStatus any, attempts int, started time.Time) { func (c *Client) logSubscribeAttempt(activityID, toUserID, userID string, templateType, templateID, openid, page string, data map[string]string, status, errorMsg string, errorCode, httpStatus any, attempts int, started time.Time) {
lookup := userID lookup := userID
if lookup == 0 { if lookup == "" {
lookup = toUserID lookup = toUserID
} }
var coupleID any var coupleID any
@@ -346,7 +316,7 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templat
coupleID = user["couple_id"] coupleID = user["couple_id"]
} }
action := "" action := ""
if activityID != 0 { if activityID != "" {
if activity, _ := c.DB.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID); activity != nil { if activity, _ := c.DB.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID); activity != nil {
action = activity.Str("action_type") action = activity.Str("action_type")
} }
@@ -358,19 +328,23 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templat
if status == "success" { if status == "success" {
sentAt = time.Now().Format("2006-01-02 15:04:05") sentAt = time.Now().Format("2006-01-02 15:04:05")
} }
content := data["thing2"]
if data["time2"] != "" {
content = data["thing1"] + " · " + data["time2"] + " · " + data["thing3"]
}
_, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, _, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
activityID, coupleID, userID, toUserID, openid, action, data["thing1"], data["thing2"], templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt) activityID, coupleID, userID, toUserID, openid, action, data["thing1"], content, templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt)
if err != nil { if err != nil {
slog.Error("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err) zap.S().Errorw("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err)
} }
} }
// LogNotifyResult 对应 logNotifyResult() // LogNotifyResult 对应 logNotifyResult()
func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateType, status, errorMsg string) { func (c *Client) LogNotifyResult(activityID, toUserID, userID string, templateType, status, errorMsg string) {
db := c.DB db := c.DB
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
lookup := userID lookup := userID
if lookup == 0 { if lookup == "" {
lookup = toUserID lookup = toUserID
} }
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup) u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup)
@@ -379,7 +353,7 @@ func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateTyp
coupleID = u["couple_id"] coupleID = u["couple_id"]
} }
actionType := "" actionType := ""
if activityID != 0 { if activityID != "" {
a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID) a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID)
if a != nil { if a != nil {
actionType = a.Str("action_type") actionType = a.Str("action_type")
@@ -394,7 +368,7 @@ func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateTyp
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now) activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
} }
if err != nil { if err != nil {
slog.Error("logNotifyResult error", "err", err) zap.S().Errorw("logNotifyResult error", "err", err)
} }
} }
@@ -407,3 +381,9 @@ func marshalUnescaped(v any) ([]byte, error) {
} }
return bytes.TrimRight(buf.Bytes(), "\n"), nil return bytes.TrimRight(buf.Bytes(), "\n"), nil
} }
func messageLockHash(value string) uint64 {
h := fnv.New64a()
h.Write([]byte(value))
return h.Sum64()
}
+116
View File
@@ -0,0 +1,116 @@
// Package wxpay integrates direct-merchant API v3 mini-program payment.
package wxpay
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"os"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"past/internal/config"
)
type PrepayOrder struct {
Number, Description, OpenID, Attach string
Amount int64
ExpiresAt time.Time
}
type Gateway interface {
Prepay(context.Context, PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error)
Query(context.Context, string) (*payments.Transaction, error)
ParseNotify(context.Context, *http.Request) (*payments.Transaction, error)
}
type Client struct {
cfg config.WechatPayConfig
service jsapi.JsapiApiService
handler *notify.Handler
}
func New(cfg config.WechatPayConfig) (*Client, error) {
if cfg.AppID == "" || cfg.MchID == "" || cfg.CertificateSerial == "" || cfg.PublicKeyID == "" || len(cfg.APIv3Key) != 32 {
return nil, errors.New("微信支付 AppID、商户号、证书序列号、公钥 ID 及 32 字节 APIv3 密钥必须配置")
}
if err := cfg.ValidateNotifyURL(); err != nil {
return nil, err
}
privateKey, err := utils.LoadPrivateKeyWithPath(cfg.PrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("加载微信支付商户私钥: %w", err)
}
publicKey, err := utils.LoadPublicKeyWithPath(cfg.PublicKeyPath)
if err != nil {
return nil, fmt.Errorf("加载微信支付公钥: %w", err)
}
if err := validateCertificate(cfg, privateKey); err != nil {
return nil, err
}
client, err := core.NewClient(context.Background(), option.WithWechatPayPublicKeyAuthCipher(cfg.MchID, cfg.CertificateSerial, privateKey, cfg.PublicKeyID, publicKey), option.WithHTTPClient(&http.Client{Timeout: 15 * time.Second}))
if err != nil {
return nil, err
}
handler, err := notify.NewRSANotifyHandler(cfg.APIv3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(cfg.PublicKeyID, *publicKey))
if err != nil {
return nil, err
}
return &Client{cfg: cfg, service: jsapi.JsapiApiService{Client: client}, handler: handler}, nil
}
func validateCertificate(cfg config.WechatPayConfig, key *rsa.PrivateKey) error {
if cfg.CertificatePath == "" {
return nil
}
data, err := os.ReadFile(cfg.CertificatePath)
if err != nil {
return err
}
block, _ := pem.Decode(data)
if block == nil {
return errors.New("无效的商户证书")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return err
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok || pub.N.Cmp(key.N) != 0 || pub.E != key.E {
return errors.New("商户证书与私钥不匹配")
}
if fmt.Sprintf("%X", cert.SerialNumber) != cfg.CertificateSerial || cert.Subject.CommonName != cfg.MchID {
return errors.New("商户证书序列号或商户号与配置不匹配")
}
if time.Now().Before(cert.NotBefore) || time.Now().After(cert.NotAfter) {
return errors.New("商户证书不在有效期内")
}
return nil
}
func (c *Client) Prepay(ctx context.Context, o PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
res, _, err := c.service.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{Appid: core.String(c.cfg.AppID), Mchid: core.String(c.cfg.MchID), Description: core.String(o.Description), OutTradeNo: core.String(o.Number), NotifyUrl: core.String(c.cfg.NotifyURL), Attach: core.String(o.Attach), TimeExpire: core.Time(o.ExpiresAt), Amount: &jsapi.Amount{Total: core.Int64(o.Amount), Currency: core.String("CNY")}, Payer: &jsapi.Payer{Openid: core.String(o.OpenID)}})
return res, err
}
func (c *Client) Query(ctx context.Context, number string) (*payments.Transaction, error) {
res, _, err := c.service.QueryOrderByOutTradeNo(ctx, jsapi.QueryOrderByOutTradeNoRequest{Mchid: core.String(c.cfg.MchID), OutTradeNo: core.String(number)})
return res, err
}
func (c *Client) ParseNotify(ctx context.Context, r *http.Request) (*payments.Transaction, error) {
tx := new(payments.Transaction)
req, err := c.handler.ParseNotifyRequest(ctx, r, tx)
if err != nil {
return nil, err
}
if req.EventType != "TRANSACTION.SUCCESS" || req.Resource == nil || req.Resource.OriginalType != "transaction" {
return nil, errors.New("不支持的微信支付通知类型")
}
return tx, nil
}
+193
View File
@@ -0,0 +1,193 @@
package wxpay
import (
"bytes"
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"past/internal/config"
)
type roundTrip func(*http.Request) (*http.Response, error)
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func sign(t *testing.T, key *rsa.PrivateKey, msg string) string {
t.Helper()
hash := sha256.Sum256([]byte(msg))
s, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(s)
}
func fixture(t *testing.T) (config.WechatPayConfig, *rsa.PrivateKey, *rsa.PrivateKey) {
t.Helper()
merchant, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
platform, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
cfg := config.WechatPayConfig{AppID: "wx-test", MchID: "1234567890", CertificateSerial: "1234", APIv3Key: "0123456789abcdef0123456789abcdef", PublicKeyID: "PUB_KEY_ID_TEST", NotifyURL: "https://past.example/api/wechatPayNotify", PrivateKeyPath: filepath.Join(dir, "key.pem"), PublicKeyPath: filepath.Join(dir, "public.pem"), CertificatePath: filepath.Join(dir, "cert.pem")}
cert := &x509.Certificate{SerialNumber: big.NewInt(0x1234), Subject: pkix.Name{CommonName: cfg.MchID}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, cert, cert, &merchant.PublicKey, merchant)
if err != nil {
t.Fatal(err)
}
pub, err := x509.MarshalPKIXPublicKey(&platform.PublicKey)
if err != nil {
t.Fatal(err)
}
privateDER, err := x509.MarshalPKCS8PrivateKey(merchant)
if err != nil {
t.Fatal(err)
}
for p, b := range map[string]*pem.Block{cfg.PrivateKeyPath: {Type: "PRIVATE KEY", Bytes: privateDER}, cfg.CertificatePath: {Type: "CERTIFICATE", Bytes: der}, cfg.PublicKeyPath: {Type: "PUBLIC KEY", Bytes: pub}} {
if err := os.WriteFile(p, pem.EncodeToMemory(b), 0600); err != nil {
t.Fatal(err)
}
}
return cfg, merchant, platform
}
func headers(t *testing.T, cfg config.WechatPayConfig, key *rsa.PrivateKey, body string) http.Header {
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "test-nonce"
return http.Header{"Wechatpay-Timestamp": {ts}, "Wechatpay-Nonce": {nonce}, "Wechatpay-Serial": {cfg.PublicKeyID}, "Wechatpay-Signature": {sign(t, key, ts+"\n"+nonce+"\n"+body+"\n")}}
}
func TestPrepayQueryAndSignatures(t *testing.T) {
cfg, merchant, platform := fixture(t)
old := http.DefaultTransport
defer func() { http.DefaultTransport = old }()
calls := 0
http.DefaultTransport = roundTrip(func(r *http.Request) (*http.Response, error) {
calls++
if r.URL.Host != "api.mch.weixin.qq.com" {
t.Fatalf("unexpected host %s", r.URL.Host)
}
body := ""
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
body = string(b)
}
auth := r.Header.Get("Authorization")
parts := map[string]string{}
for _, m := range regexp.MustCompile(`(\w+)="([^"]+)"`).FindAllStringSubmatch(auth, -1) {
parts[m[1]] = m[2]
}
msg := r.Method + "\n" + r.URL.RequestURI() + "\n" + parts["timestamp"] + "\n" + parts["nonce_str"] + "\n" + body + "\n"
hash := sha256.Sum256([]byte(msg))
sig, _ := base64.StdEncoding.DecodeString(parts["signature"])
if parts["mchid"] != cfg.MchID || parts["serial_no"] != cfg.CertificateSerial || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, hash[:], sig) != nil {
t.Fatal("merchant request signature invalid")
}
response := `{"prepay_id":"test-prepay"}`
if r.Method == "POST" {
var data map[string]any
if err := json.Unmarshal([]byte(body), &data); err != nil {
t.Fatal(err)
}
if data["appid"] != cfg.AppID || data["mchid"] != cfg.MchID || data["out_trade_no"] != "P12345678" || data["amount"].(map[string]any)["total"] != float64(500) || data["payer"].(map[string]any)["openid"] != "openid" {
t.Fatalf("incorrect prepay payload: %s", body)
}
} else {
response = `{"out_trade_no":"P12345678","trade_state":"NOTPAY"}`
}
return &http.Response{StatusCode: 200, Header: headers(t, cfg, platform, response), Body: io.NopCloser(strings.NewReader(response))}, nil
})
client, err := New(cfg)
if err != nil {
t.Fatal(err)
}
p, err := client.Prepay(context.Background(), PrepayOrder{Number: "P12345678", Description: "space", OpenID: "openid", Amount: 500, ExpiresAt: time.Now().Add(30 * time.Minute)})
if err != nil {
t.Fatal(err)
}
msg := cfg.AppID + "\n" + *p.TimeStamp + "\n" + *p.NonceStr + "\n" + *p.Package + "\n"
hash := sha256.Sum256([]byte(msg))
sig, _ := base64.StdEncoding.DecodeString(*p.PaySign)
if *p.SignType != "RSA" || *p.Package != "prepay_id=test-prepay" || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, hash[:], sig) != nil {
t.Fatal("invalid requestPayment signature")
}
q, err := client.Query(context.Background(), "P12345678")
if err != nil || *q.TradeState != "NOTPAY" || calls != 2 {
t.Fatalf("query failed: %v", err)
}
}
func TestNotifyVerifiesSignatureAndDecrypts(t *testing.T) {
cfg, _, platform := fixture(t)
client, err := New(cfg)
if err != nil {
t.Fatal(err)
}
block, _ := aes.NewCipher([]byte(cfg.APIv3Key))
gcm, _ := cipher.NewGCM(block)
nonce := "123456789012"
ad := "transaction"
plaintext := []byte(`{"out_trade_no":"P12345678","trade_state":"SUCCESS","amount":{"total":500,"currency":"CNY"}}`)
encrypted := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(ad)))
raw, _ := json.Marshal(map[string]any{"id": "notify-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": encrypted, "nonce": nonce, "associated_data": ad, "original_type": "transaction"}})
for _, mode := range []string{"valid", "signature", "ciphertext", "serial", "missing"} {
t.Run(mode, func(t *testing.T) {
body := string(raw)
if mode == "ciphertext" {
body = strings.Replace(body, encrypted, encrypted[:len(encrypted)-4]+"AAAA", 1)
}
r := httptest.NewRequest("POST", "/api/wechatPayNotify", bytes.NewBufferString(body))
r.Header = headers(t, cfg, platform, body)
switch mode {
case "signature":
r.Header.Set("Wechatpay-Signature", sign(t, platform, "wrong"))
case "serial":
r.Header.Set("Wechatpay-Serial", "PUB_KEY_ID_OTHER")
case "missing":
r.Header.Del("Wechatpay-Signature")
}
tx, err := client.ParseNotify(context.Background(), r)
if mode == "valid" {
if err != nil || *tx.OutTradeNo != "P12345678" || *tx.Amount.Total != 500 {
t.Fatalf("notify parsing: %v", err)
}
} else if err == nil {
t.Fatal("invalid notify accepted")
}
})
}
optional := cfg
optional.CertificatePath = ""
if _, err := New(optional); err != nil {
t.Fatalf("optional certificate: %v", err)
}
cfg.CertificateSerial = "BAD"
if _, err := New(cfg); err == nil {
t.Fatal("wrong merchant certificate accepted")
}
cfg.CertificateSerial = "1234"
cfg.NotifyURL = "http://localhost/callback"
if _, err := New(cfg); err == nil {
t.Fatal("insecure callback accepted")
}
}
+2
View File
@@ -13,6 +13,7 @@ import PetsPage from "@/pages/Pets"
import SubscribeMessagesPage from "@/pages/SubscribeMessages" import SubscribeMessagesPage from "@/pages/SubscribeMessages"
import SettingsPage from "@/pages/Settings" import SettingsPage from "@/pages/Settings"
import StoragePage from "@/pages/Storage" import StoragePage from "@/pages/Storage"
import FilesPage from "@/pages/Files"
// 登录态:admin_check_auth(Cookie 会话) // 登录态:admin_check_auth(Cookie 会话)
export function useAuth() { export function useAuth() {
@@ -47,6 +48,7 @@ export default function App() {
<Route path="pets" element={<PetsPage />} /> <Route path="pets" element={<PetsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="storage" element={<StoragePage />} /> <Route path="storage" element={<StoragePage />} />
<Route path="files" element={<FilesPage />} />
<Route path="subscribe-messages" element={<SubscribeMessagesPage />} /> <Route path="subscribe-messages" element={<SubscribeMessagesPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
@@ -9,7 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | null; onClose: () => void }) { export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: string | null; onClose: () => void }) {
const qc = useQueryClient() const qc = useQueryClient()
const q = useQuery({ const q = useQuery({
queryKey: ["admin_couple_detail", coupleId], queryKey: ["admin_couple_detail", coupleId],
@@ -19,7 +19,7 @@ export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | n
const [del, setDel] = useState<DeleteContentTarget | null>(null) const [del, setDel] = useState<DeleteContentTarget | null>(null)
const d = q.data const d = q.data
const users: any[] = d?.users || [] const users: any[] = d?.users || []
const delBtn = (type: string, id: number, label: string) => ( const delBtn = (type: string, id: string, label: string) => (
<Button size="xs" variant="destructive" className="ml-auto shrink-0" onClick={(e) => { e.stopPropagation(); setDel({ type, id, coupleId: coupleId!, label }) }}>🗑️ 删除</Button> <Button size="xs" variant="destructive" className="ml-auto shrink-0" onClick={(e) => { e.stopPropagation(); setDel({ type, id, coupleId: coupleId!, label }) }}>🗑️ 删除</Button>
) )
@@ -109,7 +109,7 @@ export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | n
) )
} }
function ActivityTab({ coupleId }: { coupleId: number }) { function ActivityTab({ coupleId }: { coupleId: string }) {
const q = useQuery({ queryKey: ["admin_activity_log", coupleId], queryFn: () => api<any[]>("adminActivityLog", { couple_id: coupleId }, "GET") }) const q = useQuery({ queryKey: ["admin_activity_log", coupleId], queryFn: () => api<any[]>("adminActivityLog", { couple_id: coupleId }, "GET") })
if (q.isPending) return <Loading /> if (q.isPending) return <Loading />
if (q.isError) return <ErrorBox error={q.error} /> if (q.isError) return <ErrorBox error={q.error} />

Some files were not shown because too many files have changed in this diff Show More