Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe543d4b2 | ||
|
|
b854d2873e | ||
|
|
41bcb793da | ||
|
|
8be6d9b1ab | ||
|
|
4c9708b562 | ||
|
|
4934a69581 | ||
|
|
0530523750 | ||
|
|
bf99e52937 | ||
|
|
b7c6224e54 | ||
|
|
31eae87a63 | ||
|
|
c5cab1b945 | ||
|
|
7854d94e7b | ||
|
|
c0a3db5c8e | ||
|
|
02d200fe12 | ||
|
|
2ffee9d7fb | ||
|
|
7133ea583d | ||
|
|
45c81aeaac | ||
|
|
1bafb01741 | ||
|
|
5365e13963 | ||
|
|
4e751410b3 | ||
|
|
a41c4d91cb | ||
|
|
2b2adfc0d3 | ||
|
|
92f211fc3b | ||
|
|
b10fc3c4ac | ||
|
|
a68a4ef411 |
@@ -7,6 +7,9 @@ run/
|
||||
|
||||
# 本地配置(含密钥,不入库)
|
||||
config.yaml
|
||||
config.dev.yaml
|
||||
# 生产部署配置模板(含数据库密码,不入库;部署时复制到服务器后按需修改)
|
||||
deploy/linux/config.prod.yaml
|
||||
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
@@ -16,3 +19,9 @@ dist-ssr/
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 微信支付本地证书及私钥
|
||||
past-go/cert/
|
||||
|
||||
# zap 日志及压缩轮转文件
|
||||
log/
|
||||
|
||||
Generated
+10
@@ -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
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+9
@@ -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>
|
||||
@@ -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 下发。新成员需向项目负责人领取配置与证书,或从示例文件配置自己的开发环境;现有服务器更新时保留线上配置、证书及数据。
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -4,7 +4,7 @@ GOOS ?= linux
|
||||
GOARCH ?= amd64
|
||||
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
|
||||
|
||||
@@ -33,12 +33,12 @@ build-local:
|
||||
run: build-local
|
||||
./run/$(BIN) -config config.yaml
|
||||
|
||||
## 本地调试:使用不入库的 config.dev.yaml
|
||||
dev: build-local
|
||||
./run/$(BIN) -config config.dev.yaml
|
||||
|
||||
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:
|
||||
rm -rf dist run/$(BIN) web/admin/dist
|
||||
|
||||
+68
-43
@@ -1,57 +1,82 @@
|
||||
# 我们past后端(Go 版)
|
||||
# 我们 past 后端
|
||||
|
||||
`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。
|
||||
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
|
||||
开发、打包、宝塔更新、验证与回滚步骤见 **[Linux / 宝塔部署文档](deploy/README.md)**。仓库总览见 [根目录 README](../README.md)。以下为配置与业务说明。
|
||||
|
||||
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。
|
||||
# 微信支付 API v3
|
||||
|
||||
## 目录
|
||||
空间会员通过微信小程序 `wx.requestPayment` 支付,服务端使用官方 `wechatpay-go` SDK(与 plant-go 同样采用微信支付公钥验签模式)。线上域名为 `we.sundynix.cn`。
|
||||
|
||||
```
|
||||
cmd/server/ 入口
|
||||
internal/config/ config.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/
|
||||
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session
|
||||
internal/geo/ 腾讯位置服务
|
||||
internal/notify/ cron_notify.php 的进程内定时版
|
||||
web/admin/ React 管理后台(dist/ 被 embed)
|
||||
配置文件中的 `wechat_pay` 包含商户号、证书序列号、APIv3 密钥、商户私钥、微信支付公钥 ID/路径与通知地址。设置 `enabled: true` 后启用;`appid` 留空时复用 `wechat.appid`,必须为商户已绑定的小程序。所有证书路径相对于所选 YAML 文件解析。
|
||||
`certificate_path` 可选,填写后校验证书商户号、序列号、有效期及私钥匹配;请求签名只使用商户私钥。
|
||||
本机证书放在已忽略 Git 的 `cert/`,真实密钥仅存在本地 `config.dev.yaml`,模板不含密钥。
|
||||
|
||||
接口:
|
||||
|
||||
- `POST /api/storageCreateOrder`:传服务端当前上架套餐的 `planKey`(见 `storageInfo`),并携带页面取得的 `expectedPriceCents` 和 `expectedPlanVersion`;价格由服务端收费设置决定。先持久化订单,再调用预下单,返回 `outTradeNo` 与 `payment` 签名参数。
|
||||
- `GET /api/storageOrderStatus?outTradeNo=...`:仅允许查询本人订单,必要时向微信主动查单并补开通。
|
||||
- `POST https://we.sundynix.cn/api/wechatPayNotify`:无需用户登录,通过微信支付签名校验、APIv3 AES-GCM 解密后核对商户、AppID、付款人、金额和币种;数据库事务提交后返回 204。
|
||||
|
||||
订单保存支付身份和金额快照,并通过行锁、交易号检查实现重复通知幂等。会员已到期时从当前时间续期,未到期时累计续期;更新会员和订单状态在同一事务完成。服务每分钟补查最近 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
|
||||
cp config.example.yaml config.yaml # 填写数据库 / 微信配置
|
||||
go build -o past-server ./cmd/server
|
||||
./past-server -config config.yaml
|
||||
```
|
||||
新增、修改、删除通过 GORM 模型执行;复杂报表通过 GORM Raw 执行,并统一过滤所有关联表及子查询中的 `deleted_at`。
|
||||
删除保留原行及附件;活跃记录的唯一索引带 `WHERE deleted_at IS NULL`,删除后可以重新创建同业务键的记录。
|
||||
用户对外的六位展示编号是独立业务字段,继续使用数据库序列分配;它不是 UUID 主键。情侣空间编号为 `CP` + 不带连字符的情侣 UUID。
|
||||
|
||||
运行目录布局(`data_dir`,默认为二进制所在目录):
|
||||
本次创建的是新的 PostgreSQL 库,不会导入旧 MySQL 数据。历史备份如需迁入,需要单独完成数据转换和旧 ID 到 UUID 的关联映射。
|
||||
|
||||
```
|
||||
data_dir/
|
||||
├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移)
|
||||
└── data/ access_token.cache 等
|
||||
```
|
||||
验证:`go test ./...`、`go vet ./...`;在设置 `PAST_TEST_POSTGRES_DSN` 后,可运行 `go test ./internal/store -run TestPostgresIntegration -v` 验证真实 PostgreSQL(测试数据全部回滚)。
|
||||
|
||||
URL 映射:
|
||||
MinIO 配置读取 `minio` 节点。`past_files` 保存 UUID、size、suffix、content_type、object_key、md5、sha256、url、bucket 等元数据;对象键格式为 `YYYY/MM/<md5>.<suffix>`。同内容并发上传通过 PostgreSQL 锁和唯一索引去重,不重复上传对象。
|
||||
|
||||
| URL | 说明 |
|
||||
|---|---|
|
||||
| `/api/接口名` | 小程序、管理后台、上传统一入口 |
|
||||
| `/backend/api/index.php?action=…` | 旧客户端兼容入口 |
|
||||
| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) |
|
||||
| `/backend/admin/` | 管理后台 SPA |
|
||||
| `/healthz` | 健康检查 |
|
||||
头像、相册封面、照片、打卡图片和背景使用带外键的文件 ID 字段;日记/记忆日图片使用 `past_record_file` 按 position 保存多图顺序。旧接口的路径字段在数据库中仅保存 ID 镜像,响应时解析为稳定文件访问地址;新请求可直接提交 `fileId`、`avatarFileId`、`coverFileId`、`imageFileIds` 等字段。`past_file_upload` 独立记录每个上传用户,既保留共享去重,也保证权限与容量统计正确。
|
||||
|
||||
## 与 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)。
|
||||
- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。
|
||||
- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。
|
||||
- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。
|
||||
- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。
|
||||
真实 PostgreSQL/MinIO 回归:设置 `PAST_TEST_POSTGRES_DSN` 后运行 `go test ./...`。测试读取本地 `config.yaml` 中的 MinIO 连接,可通过 `PAST_TEST_CONFIG` 指定其他配置文件的绝对路径,使用隔离的 schema/bucket,测试结束清理;微信/地图外部请求使用模拟依赖。
|
||||
|
||||
构建及部署:在 `past-go/` 目录执行 `make build-local` 生成本机服务;管理端资源已存在时可直接构建,修改管理端后先执行 `npm --prefix web/admin run build`。
|
||||
Linux 发布使用 `make all`,Windows 发布使用 `make web build-windows`。
|
||||
部署配置见 [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
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"past/internal/config"
|
||||
"past/internal/store"
|
||||
)
|
||||
|
||||
type result struct {
|
||||
@@ -21,43 +18,15 @@ type result struct {
|
||||
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}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(ctx, statement)
|
||||
rows, err := db.Query(statement)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
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()
|
||||
for _, row := range rows {
|
||||
r.Rows = append(r.Rows, map[string]any(row))
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -73,30 +42,22 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.DSN())
|
||||
db, err := store.Open(cfg.DSN())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open database connection")
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
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{
|
||||
query(db, "database", "SELECT 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, "database", "SELECT CURRENT_DATABASE() AS database_name, VERSION() AS version, NOW() AS database_time"),
|
||||
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, "migration_marker", "SELECT `key`,`value` FROM settings WHERE `key`='account_records_v1'"),
|
||||
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'"),
|
||||
}
|
||||
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))
|
||||
}
|
||||
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")
|
||||
if err != nil {
|
||||
|
||||
@@ -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
-23
@@ -5,8 +5,7 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -15,14 +14,17 @@ import (
|
||||
"time"
|
||||
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
|
||||
|
||||
"past/internal/cache"
|
||||
"past/internal/config"
|
||||
"past/internal/geo"
|
||||
"past/internal/handler"
|
||||
"past/internal/httpapi"
|
||||
"past/internal/logging"
|
||||
"past/internal/notify"
|
||||
"past/internal/store"
|
||||
"past/internal/upload"
|
||||
"past/internal/wechat"
|
||||
"past/internal/wxpay"
|
||||
"past/web"
|
||||
)
|
||||
|
||||
@@ -31,6 +33,7 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "config.yaml", "配置文件路径")
|
||||
resetAdmin := flag.Bool("reset-admin", false, "将管理端账号密码设置为 past/past 后退出")
|
||||
showVer := flag.Bool("version", false, "显示版本")
|
||||
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
|
||||
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) }) {
|
||||
return
|
||||
}
|
||||
@@ -78,43 +101,52 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
slog.Error("配置加载失败", "err", err)
|
||||
fmt.Fprintln(os.Stderr, "配置加载失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 确保必要目录存在(对应 config.php 中的 $dirs)
|
||||
for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} {
|
||||
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755)
|
||||
log, closeLog, err := logging.New(cfg.LogDir(), cfg.Log)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "日志初始化失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径)
|
||||
logOut := io.Writer(os.Stdout)
|
||||
if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
logOut = io.MultiWriter(os.Stdout, f)
|
||||
defer f.Close()
|
||||
defer closeLog()
|
||||
undoZap := zap.ReplaceGlobals(log)
|
||||
defer undoZap()
|
||||
undoStdLog := zap.RedirectStdLog(log)
|
||||
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())
|
||||
if err != nil {
|
||||
slog.Error("数据库连接失败", "err", err)
|
||||
zap.S().Errorw("数据库连接失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db.UploadDir = cfg.UploadDir()
|
||||
db.NumericAsString = cfg.PHPCompat < "8.1"
|
||||
if err := db.InitSchema(); err != nil {
|
||||
slog.Error("数据库初始化失败", "err", err)
|
||||
zap.S().Errorw("数据库初始化失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := db.MigrateSmokingRecords(); err != nil {
|
||||
slog.Error("抽烟库存迁移失败", "err", err)
|
||||
zap.S().Errorw("抽烟库存迁移失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.AdvanceUnbindings(); err != nil {
|
||||
slog.Error("解绑状态处理失败", "err", err)
|
||||
zap.S().Errorw("解绑状态处理失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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{
|
||||
Cfg: cfg,
|
||||
DB: db,
|
||||
@@ -122,11 +154,24 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
Geo: geo.New(cfg.TencentMapKey),
|
||||
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())
|
||||
go uploader.RunFileCleanupWorker()
|
||||
handler.Register(router, uploader)
|
||||
slog.Info("已注册 action", "count", len(router.Actions()))
|
||||
zap.S().Infow("已注册 action", "count", len(router.Actions()))
|
||||
|
||||
if cfg.NotifyIntervalMin > 0 {
|
||||
go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute)
|
||||
@@ -141,9 +186,9 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
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 {
|
||||
slog.Error("服务异常退出", "err", err)
|
||||
zap.S().Errorw("服务异常退出", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
@@ -152,5 +197,5 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
slog.Info("服务已停止")
|
||||
zap.S().Infow("服务已停止")
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func serviceControl(cmd, cfgPath string) error {
|
||||
}
|
||||
s, err := m.CreateService(svcName, exe, mgr.Config{
|
||||
DisplayName: "我们past Go 服务 (past-server)",
|
||||
Description: "past.sundynix.cn 后端 API 与管理后台",
|
||||
Description: "we.sundynix.cn 后端 API 与管理后台",
|
||||
StartType: mgr.StartAutomatic,
|
||||
}, "-config", abs)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
@@ -3,16 +3,50 @@ listen: ":8080"
|
||||
# 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录
|
||||
data_dir: ""
|
||||
db:
|
||||
host: 127.0.0.1
|
||||
port: 3306
|
||||
name: past
|
||||
user: past
|
||||
pass: your_db_password
|
||||
host: 192.168.100.127
|
||||
port: 5432
|
||||
name: past-go
|
||||
user: root
|
||||
pass: root
|
||||
sslmode: disable
|
||||
wechat:
|
||||
appid: your_appid
|
||||
secret: your_secret
|
||||
notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8
|
||||
notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" # 操作与打卡通知共用模板(15198)
|
||||
tencent_map_key: ""
|
||||
max_upload_mb: 50
|
||||
# 定时补发通知间隔(分钟),0 关闭
|
||||
notify_interval_min: 5
|
||||
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
|
||||
|
||||
+282
-43
@@ -1,60 +1,299 @@
|
||||
# 20261008 原权限兼容与抽烟功能
|
||||
# Linux / 宝塔部署文档
|
||||
|
||||
## 当前行为
|
||||
本文是当前部署入口,适用于 PostgreSQL + Redis + MinIO + 微信支付 API v3。以下操作在本地构建后,通过宝塔「文件」上传,再在宝塔「终端」手动执行;不需要配置计划任务,也不要求开发人员拥有 SSH。
|
||||
|
||||
- 日记、相册、记忆日、歌曲和普通打卡恢复原版按情侣成员作者查询;历史 couple_id 不再成为新增过滤条件。未绑定展示方式恢复原版。
|
||||
- 姨姨继续使用 period_records、period_daily_records、period_sex_records,恢复原版共享记录、来源选择和可见开关。启动不复制或切换姨姨表。此前创建的 personal_period_* 表保留,但本版不读写。
|
||||
- 照片响应保持原始 uploads/... 路径,不签名、不改成 /backend/uploads/...;上传静态文件恢复原访问方式,兼容正式版小程序。
|
||||
- 抽烟使用独立的 smoking_wallets 和 smoking_entries;消耗一支扣一支、买烟和别人递烟加库存、撤销操作保持事务和幂等。库存随账号保留。
|
||||
- 72 小时解绑冷静期继续保留:双方同意后开始计时,期间可取消,到期解除绑定且保留记录。其他数据权限按原版运行。
|
||||
## 1. 当前环境与目录
|
||||
|
||||
## 部署
|
||||
| 项目 | 当前生产值 |
|
||||
| --- | --- |
|
||||
| 系统 / 架构 | 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` 和数据库。示例配置仅供参考,不要覆盖现有配置。
|
||||
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。
|
||||
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。
|
||||
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。
|
||||
5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。
|
||||
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。
|
||||
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。
|
||||
```bash
|
||||
cd past-go
|
||||
cp config.dev.example.yaml config.dev.yaml
|
||||
# 编辑 config.dev.yaml,补齐自己的开发配置,再继续:
|
||||
make web
|
||||
make dev
|
||||
```
|
||||
|
||||
## 接口规则
|
||||
首次拉取没有 `web/admin/dist`,必须先执行 `make web`,否则 Go 的 `go:embed` 编译会报资源不存在。`make dev` 构建本机程序并使用 `config.dev.yaml` 启动,默认监听 `:8080`。
|
||||
|
||||
- 请求地址:`/api/接口名`,例如 `/api/periodList`、`/api/diaryList?page=1&pageSize=20`。
|
||||
- 小程序、管理后台、上传统一使用新路径;不再附加 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/` 下。
|
||||
本地管理端:`http://127.0.0.1:8080/backend/admin/login`。
|
||||
|
||||
## 小程序验证
|
||||
开发配置需要填写:
|
||||
|
||||
- Android 与 iPhone:标题与微信胶囊不重叠,首屏和底部内容不被遮挡。
|
||||
- 首页:未绑定显示注册天数和邀请入口,绑定后显示在一起天数;解绑后首页、我的立即按接口更新。
|
||||
- 三个底部入口:首页、记录、我的;详情返回与发布、编辑流程正常。
|
||||
- 日记和相册:首次加载、空列表、网络失败分别展示;创建相册与上传照片正常。
|
||||
- 姨姨:对方可见单独操作;关闭自己的开关不能把自己锁住,权限由 API 判断。双方都有绑定前数据时仍选择唯一记录。
|
||||
- 改周期、持续天数、开始和结束日期保存后,重新进入展示服务端最新值。
|
||||
- PostgreSQL:地址、端口、数据库、账号密码。启动会检查并创建目标库、执行 GORM AutoMigrate;账号需要对应权限。
|
||||
- Redis:地址、认证、DB、`key_prefix`。Redis 是启动依赖,连接失败时后端不会启动。
|
||||
- MinIO:endpoint、access/secret key、bucket、SSL 配置;文件通过 `/api/files/:fileId` 访问。
|
||||
- 微信:AppID、AppSecret、订阅消息模板 ID;AppSecret 只放后端。
|
||||
- `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`;若某功能直接访问其他域名的对象或外部图片,需要同时核对该实际域名的配置。
|
||||
|
||||
每次发布同时记录后端版本、小程序版本和验证结果,避免协同开发时把本地测试地址随代码上传到正式版。
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
-- 只读排查:在原业务数据库中执行。不要修改关系、记录归属或迁移标记。
|
||||
SELECT DATABASE() AS current_database;
|
||||
-- 只读排查:在 PostgreSQL past-go 数据库中执行。不要修改关系、记录归属或迁移标记。
|
||||
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
|
||||
UNION ALL SELECT 'couples', COUNT(*) FROM couples
|
||||
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;
|
||||
|
||||
-- 关系一致性:只显示内部 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,
|
||||
u1.couple_id AS user1_current_couple, u2.couple_id AS user2_current_couple
|
||||
FROM couples c
|
||||
@@ -20,6 +92,42 @@ LEFT JOIN users u2 ON u2.id = c.user2_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,
|
||||
COUNT(*) AS records,
|
||||
SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 放在 past.sundynix.cn 现有 HTTPS server 块里。
|
||||
# 放在 we.sundynix.cn 现有 HTTPS server 块里。
|
||||
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。
|
||||
location = / {
|
||||
return 302 /backend/admin/login;
|
||||
}
|
||||
|
||||
location = /api {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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) 部署说明
|
||||
|
||||
后端 Go 服务通过 `//go:embed` 把 React 管理后台(`web/admin/dist`)一并打进 `past-server.exe`,
|
||||
是**单体可执行文件**:无需单独部署前端、无需安装 Node.js 运行环境,管理后台从
|
||||
`https://你的域名/backend/admin/` 直接访问。
|
||||
|
||||
## 部署包内容
|
||||
|
||||
```
|
||||
windows/
|
||||
├── past-server.exe # 单体服务(API + 管理后台)
|
||||
├── config.yaml # 服务配置(由 config.example.yaml 复制,需填写真实值)
|
||||
├── install-service.ps1 # 安装 / 卸载 Windows 服务脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。
|
||||
2. **填写配置**:编辑 `config.yaml`(数据库、微信、微信支付 API v3 等)。
|
||||
- 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。
|
||||
- **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`,
|
||||
`../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。
|
||||
3. **安装为 Windows 服务**(以管理员身份运行 PowerShell):
|
||||
```powershell
|
||||
cd C:\path\to\windows
|
||||
.\install-service.ps1
|
||||
```
|
||||
脚本会执行 `past-server.exe -service install -config <绝对路径>\config.yaml` 并启动服务,
|
||||
同时配置异常退出自动重启(3s / 3s / 10s)。
|
||||
4. **配置 Nginx**:把 `/api/`、`/backend/`、`/backend/uploads/` 转发到后端端口(参考 `../nginx-api.conf`),
|
||||
并配置已备案域名与 HTTPS 证书。
|
||||
5. **验证**:
|
||||
- `https://你的域名/api` 返回 API 名称与版本;
|
||||
- `https://你的域名/backend/admin/` 打开管理后台;
|
||||
- 本机 `curl http://127.0.0.1:8081/healthz` 返回 `ok`。
|
||||
|
||||
## 服务管理
|
||||
|
||||
```powershell
|
||||
.\install-service.ps1 -Status # 查看服务状态
|
||||
.\install-service.ps1 -Restart # 重启服务
|
||||
.\install-service.ps1 -Uninstall # 停止并卸载服务
|
||||
```
|
||||
|
||||
等价命令行(不经过脚本):
|
||||
|
||||
```powershell
|
||||
.\past-server.exe -service install -config C:\path\config.yaml
|
||||
.\past-server.exe -service start
|
||||
.\past-server.exe -service stop
|
||||
.\past-server.exe -service uninstall
|
||||
```
|
||||
|
||||
## 升级(替换新版本)
|
||||
|
||||
1. 备份数据库与 `uploads/`、`data/` 目录;
|
||||
2. `.\install-service.ps1 -Uninstall`(或先停止服务);
|
||||
3. 用新的 `past-server.exe` 覆盖旧文件,**保留现有 `config.yaml`**(不要用 `config.example.yaml` 覆盖);
|
||||
4. `.\install-service.ps1` 重新安装并启动。
|
||||
|
||||
## 说明
|
||||
|
||||
- 服务以 `LocalSystem` 身份运行,工作目录为 `System32`;程序已按「可执行文件所在目录」解析
|
||||
`config.yaml` 与 `data_dir`,因此部署目录可任意放置。
|
||||
- 数据库表结构由程序启动时自动迁移,无需手工执行 SQL。
|
||||
- 二进制内嵌 `tzdata`,Windows 无系统时区库也能正确使用 `Asia/Shanghai` 时区。
|
||||
@@ -0,0 +1,55 @@
|
||||
# past-server Windows service helper.
|
||||
# Run this script from an ELEVATED PowerShell.
|
||||
#
|
||||
# Usage:
|
||||
# .\install-service.ps1 install and start the service
|
||||
# .\install-service.ps1 -Uninstall stop and remove the service
|
||||
# .\install-service.ps1 -Restart restart the service
|
||||
# .\install-service.ps1 -Status show service status
|
||||
|
||||
param(
|
||||
[switch]$Uninstall,
|
||||
[switch]$Restart,
|
||||
[switch]$Status
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ServiceName = 'past-server'
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Exe = Join-Path $ScriptDir 'past-server.exe'
|
||||
$Cfg = Join-Path $ScriptDir 'config.yaml'
|
||||
|
||||
if (-not (Test-Path $Exe)) {
|
||||
Write-Host "[ERROR] past-server.exe not found: $Exe" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
Get-Service -Name $ServiceName -ErrorAction SilentlyContinue |
|
||||
Select-Object Name, Status, StartType | Format-Table -AutoSize
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
Write-Host "[INFO] Uninstalling service '$ServiceName' ..." -ForegroundColor Yellow
|
||||
& $Exe -service uninstall
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($Restart) {
|
||||
Write-Host "[INFO] Restarting service '$ServiceName' ..." -ForegroundColor Yellow
|
||||
& $Exe -service stop
|
||||
Start-Sleep -Seconds 2
|
||||
& $Exe -service start
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Cfg)) {
|
||||
Write-Host "[ERROR] config.yaml not found: $Cfg" -ForegroundColor Red
|
||||
Write-Host "[INFO] Copy config.example.yaml to config.yaml and fill in DB / WeChat / virtual-pay settings first." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[INFO] Installing service '$ServiceName' ..." -ForegroundColor Yellow
|
||||
& $Exe -service install -config $Cfg
|
||||
exit $LASTEXITCODE
|
||||
+36
-7
@@ -3,19 +3,29 @@ module past
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.40.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/sys v0.48.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.3
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.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/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // 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/goccy/go-json v0.10.5 // 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/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/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/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/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/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.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/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
|
||||
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
@@ -1,16 +1,26 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
||||
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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
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/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
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/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-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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/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.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/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.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
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/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||
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.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.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.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.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.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.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/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/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/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/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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
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/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
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/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
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.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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=
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
@@ -3,17 +3,71 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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 {
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
WechatPay WechatPayConfig `yaml:"wechat_pay"`
|
||||
Minio MinioConfig `yaml:"minio"`
|
||||
|
||||
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
|
||||
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
|
||||
|
||||
// DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。
|
||||
// DataDir 运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储在 MinIO。
|
||||
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
|
||||
DataDir string `yaml:"data_dir"`
|
||||
|
||||
@@ -23,6 +77,7 @@ type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
User string `yaml:"user"`
|
||||
Pass string `yaml:"pass"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
} `yaml:"db"`
|
||||
|
||||
Wechat struct {
|
||||
@@ -46,14 +101,18 @@ type Config struct {
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
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.DB.Host = "127.0.0.1"
|
||||
c.DB.Port = 3306
|
||||
c.DB.Name = "past"
|
||||
c.DB.User = "past"
|
||||
c.DB.Host = "192.168.100.127"
|
||||
c.DB.Port = 5432
|
||||
c.DB.Name = "past-go"
|
||||
c.DB.User = "root"
|
||||
c.DB.Pass = "root"
|
||||
c.DB.SSLMode = "disable"
|
||||
c.MaxUploadMB = 50
|
||||
c.NotifyIntervalMin = 5
|
||||
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8"
|
||||
c.Wechat.NotifyTemplateID = "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU"
|
||||
c.PHPCompat = "8.0"
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
@@ -63,6 +122,18 @@ func Load(path string) (*Config, error) {
|
||||
if err := yaml.Unmarshal(b, c); err != nil {
|
||||
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 == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -79,12 +150,19 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
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 {
|
||||
// interpolateParams=true:统一走文本协议,所有列以字节返回,由 store 按列类型转换(对齐 PDO 行为)
|
||||
// time_zone 会在驱动建立的每个 MySQL 连接上设置会话时区,保证 SQL NOW()/CURRENT_TIMESTAMP
|
||||
// 与 Go 进程的 Asia/Shanghai 一致。使用固定 +08:00 不依赖 MySQL 时区表是否安装。
|
||||
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",
|
||||
c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name)
|
||||
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}
|
||||
q := url.Values{}
|
||||
mode := c.DB.SSLMode
|
||||
if mode == "" {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,27 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestDSNSetsShanghaiOffsetForEveryConnection(t *testing.T) {
|
||||
func TestPostgresDSN(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.DB.Host = "127.0.0.1"
|
||||
c.DB.Port = 3306
|
||||
c.DB.Name = "past"
|
||||
c.DB.User = "past"
|
||||
got := c.DSN()
|
||||
if !strings.Contains(got, "time_zone=%27%2B08%3A00%27") {
|
||||
t.Fatalf("DSN does not set MySQL session time zone: %s", got)
|
||||
}
|
||||
parsed, err := mysql.ParseDSN(got)
|
||||
c.DB.Host = "192.168.100.127"
|
||||
c.DB.Port = 5432
|
||||
c.DB.Name = "past-go"
|
||||
c.DB.User = "root"
|
||||
c.DB.Pass = "a' @:/?&\\密码"
|
||||
c.DB.SSLMode = "disable"
|
||||
parsed, err := pgx.ParseConfig(c.DSN())
|
||||
if err != nil {
|
||||
t.Fatalf("invalid DSN: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Params["time_zone"] != "'+08:00'" {
|
||||
t.Fatalf("unexpected decoded MySQL time_zone: %q", parsed.Params["time_zone"])
|
||||
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("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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "账号及内容已删除,独占的存储文件正在清理"})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func registerAdmin(r *httpapi.Router) {
|
||||
r.Handle("admin_files", adminFiles)
|
||||
r.Handle("admin_login", adminLogin)
|
||||
r.Handle("admin_check_auth", adminCheckAuth)
|
||||
r.Handle("admin_logout", adminLogout)
|
||||
@@ -53,7 +54,7 @@ func adminLogin(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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.Err(401, "用户名或密码错误")
|
||||
@@ -95,7 +96,7 @@ func adminStatsDetail(c *httpapi.Ctx) error {
|
||||
}
|
||||
month := date[:7]
|
||||
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{}
|
||||
var total int64
|
||||
@@ -107,13 +108,14 @@ func adminStatsDetail(c *httpapi.Ctx) error {
|
||||
return c.OK(M{
|
||||
"date": date,
|
||||
"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_couples": dateCount("couples"),
|
||||
"date_contents": contents,
|
||||
"date_contents_total": total,
|
||||
"total_users": tableCount(c, "users"),
|
||||
"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 共用)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -166,13 +168,13 @@ func rowOrFalse(r store.Row) any {
|
||||
}
|
||||
|
||||
func adminCoupleDetail(c *httpapi.Ctx) error {
|
||||
var cid int64
|
||||
var cid string
|
||||
if c.Method == "GET" {
|
||||
cid = c.QInt("couple_id")
|
||||
cid = c.Q("couple_id")
|
||||
} else {
|
||||
cid = c.InInt("couple_id")
|
||||
cid = c.InTrim("couple_id")
|
||||
}
|
||||
if cid == 0 {
|
||||
if cid == "" {
|
||||
return c.Err(400, "缺少情侣ID")
|
||||
}
|
||||
data := M{}
|
||||
@@ -241,8 +243,8 @@ func adminCoupleDetail(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func adminPetDetail(c *httpapi.Ctx) error {
|
||||
cid := c.QInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.Q("couple_id")
|
||||
if cid == "" {
|
||||
return c.Err(400, "缺少情侣ID")
|
||||
}
|
||||
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
|
||||
@@ -302,9 +304,12 @@ func adminUsers(c *httpapi.Ctx) error {
|
||||
}
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
if isNumeric(search) {
|
||||
where = append(where, "(u.id = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
|
||||
params = append(params, store.ToInt(search), store.ToInt(search), like, like)
|
||||
if store.ValidUUID(search) {
|
||||
where = append(where, "(u.id = ? OR u.nickname LIKE ? OR u.username 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 {
|
||||
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)")
|
||||
params = append(params, like, like)
|
||||
@@ -322,7 +327,16 @@ func adminUsers(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(paged(c, rs))
|
||||
pg := paged(c, rs)
|
||||
items := pg["items"].([]store.Row)
|
||||
for _, it := range items {
|
||||
uid, cid := it.Str("id"), it.Str("couple_id")
|
||||
it["used_bytes"] = storageUsedBytes(c, uid, cid)
|
||||
it["storage_vip"] = isStorageVip(c, uid)
|
||||
it["storage_vip_expire_at"] = storageVipExpireAt(c, uid)
|
||||
}
|
||||
pg["items"] = items
|
||||
return c.OK(pg)
|
||||
}
|
||||
|
||||
func adminFixLastLogin(c *httpapi.Ctx) error {
|
||||
@@ -335,7 +349,7 @@ func adminFixLastLogin(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 {
|
||||
return err
|
||||
}
|
||||
@@ -344,8 +358,8 @@ func adminCleanLocations(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func adminUserDetail(c *httpapi.Ctx) error {
|
||||
uid := c.QInt("user_id")
|
||||
if uid == 0 {
|
||||
uid := c.Q("user_id")
|
||||
if uid == "" {
|
||||
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)
|
||||
@@ -355,8 +369,8 @@ func adminUserDetail(c *httpapi.Ctx) error {
|
||||
if user == nil {
|
||||
return c.Err(404, "用户不存在")
|
||||
}
|
||||
if user.Int("couple_id") != 0 {
|
||||
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)
|
||||
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.Str("couple_id"), uid)
|
||||
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)
|
||||
@@ -369,7 +383,7 @@ func adminUserDetail(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
user["checkins"] = rows(checkins)
|
||||
@@ -390,8 +404,8 @@ func adminChangePassword(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.InStr("new_password")) < 6 {
|
||||
return c.Err(400, "新密码至少6位")
|
||||
if len(c.InStr("new_password")) < 4 {
|
||||
return c.Err(400, "新密码至少4位")
|
||||
}
|
||||
sess := c.AdminSession()
|
||||
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
|
||||
@@ -424,8 +438,8 @@ func adminUpdateUser(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID := c.InInt("user_id")
|
||||
if userID <= 0 {
|
||||
userID := c.InTrim("user_id")
|
||||
if userID == "" {
|
||||
return c.Err(400, "用户ID无效")
|
||||
}
|
||||
var fields []string
|
||||
@@ -460,8 +474,8 @@ func adminDeleteUser(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
target := c.InInt("user_id")
|
||||
if target <= 0 {
|
||||
target := c.InTrim("user_id")
|
||||
if target == "" {
|
||||
return c.Err(400, "用户ID无效")
|
||||
}
|
||||
tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target)
|
||||
@@ -469,17 +483,17 @@ func adminDeleteUser(c *httpapi.Ctx) error {
|
||||
return c.Err(404, "用户不存在")
|
||||
}
|
||||
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)
|
||||
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"} {
|
||||
c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid)
|
||||
}
|
||||
albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
|
||||
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 couples WHERE id = ?", cid)
|
||||
@@ -570,8 +584,8 @@ func adminForceUnbind(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.InInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.InTrim("couple_id")
|
||||
if cid == "" {
|
||||
return c.Err(400, "缺少情侣ID")
|
||||
}
|
||||
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
|
||||
@@ -595,8 +609,8 @@ func adminRestoreCouple(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.InInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.InTrim("couple_id")
|
||||
if cid == "" {
|
||||
return c.Err(400, "缺少情侣ID")
|
||||
}
|
||||
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid)
|
||||
@@ -610,8 +624,8 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.InInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.InTrim("couple_id")
|
||||
if cid == "" {
|
||||
return c.Err(400, "缺少情侣ID")
|
||||
}
|
||||
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid)
|
||||
@@ -621,7 +635,7 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error {
|
||||
if couple.Int("data_deleted") == 1 {
|
||||
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)
|
||||
executeUnbind(c.DB, cid)
|
||||
return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"})
|
||||
@@ -632,8 +646,8 @@ func adminDeleteContent(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
typ := c.InStr("type")
|
||||
id, coupleID := c.InInt("id"), c.InInt("couple_id")
|
||||
if id == 0 || coupleID == 0 {
|
||||
id, coupleID := c.InTrim("id"), c.InTrim("couple_id")
|
||||
if id == "" || coupleID == "" {
|
||||
return c.Err(400, "参数不完整")
|
||||
}
|
||||
switch typ {
|
||||
@@ -646,7 +660,6 @@ func adminDeleteContent(c *httpapi.Ctx) error {
|
||||
c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID)
|
||||
case "photo":
|
||||
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)
|
||||
}
|
||||
case "song":
|
||||
@@ -665,16 +678,16 @@ func adminContents(c *httpapi.Ctx) error {
|
||||
if !c.QHas("type") {
|
||||
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")
|
||||
page, pageSize, offset := pageParams(c, 10, 50)
|
||||
var where []string
|
||||
var params []any
|
||||
if coupleID > 0 {
|
||||
if coupleID != "" {
|
||||
where = append(where, "t.couple_id = ?")
|
||||
params = append(params, coupleID)
|
||||
}
|
||||
if userID > 0 {
|
||||
if userID != "" {
|
||||
where = append(where, "t.created_by = ?")
|
||||
params = append(params, userID)
|
||||
}
|
||||
@@ -690,11 +703,11 @@ func adminContents(c *httpapi.Ctx) error {
|
||||
if len(where) > 0 {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -743,9 +756,9 @@ func adminResetUserPassword(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID := c.InInt("user_id")
|
||||
userID := c.InTrim("user_id")
|
||||
pw := c.InStr("new_password")
|
||||
if userID <= 0 {
|
||||
if userID == "" {
|
||||
return c.Err(400, "用户ID无效")
|
||||
}
|
||||
if len(pw) < 6 {
|
||||
@@ -758,8 +771,8 @@ func adminResetUserPassword(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func adminActivityLog(c *httpapi.Ctx) error {
|
||||
cid := c.QInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.Q("couple_id")
|
||||
if cid == "" {
|
||||
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)
|
||||
@@ -770,11 +783,11 @@ func adminActivityLog(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 {
|
||||
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 {
|
||||
@@ -782,25 +795,26 @@ func adminSetGlobalBg(c *httpapi.Ctx) error {
|
||||
if fh == nil {
|
||||
return c.Err(400, "没有上传文件")
|
||||
}
|
||||
res := up.Handle(fh, "", "global")
|
||||
res := up.HandleFor(fh, "")
|
||||
if 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
|
||||
}
|
||||
c.UploadResult = &res
|
||||
return c.OK(M{"success": true, "path": res.Path})
|
||||
}
|
||||
|
||||
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 c.OK(M{"success": true})
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -808,11 +822,11 @@ func adminCoupleBgList(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func adminClearCoupleBg(c *httpapi.Ctx) error {
|
||||
cid := c.InInt("couple_id")
|
||||
if cid == 0 {
|
||||
cid := c.InTrim("couple_id")
|
||||
if cid == "" {
|
||||
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 c.OK(M{"success": true})
|
||||
|
||||
@@ -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)))})
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func adminSubscribeLogs(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestAdminSubscribeLogsFilteredServerPagination(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}, 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)
|
||||
for _, want := range []string{`"total":21`, `"total_pages":3`, `"page":2`, `recipient-openid`, `用户未订阅`} {
|
||||
|
||||
@@ -26,7 +26,7 @@ func registerAlbum(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if row == nil {
|
||||
return ""
|
||||
@@ -34,23 +34,31 @@ func coupleSpaceID(c *httpapi.Ctx, cid int64) string {
|
||||
return row.Str("couple_space_id")
|
||||
}
|
||||
|
||||
// Album access follows the creator: personal before binding, shared with the current partner afterwards.
|
||||
func albumUserIds(c *httpapi.Ctx) []string {
|
||||
if ids := coupleUserIds(c, ""); len(ids) == 2 {
|
||||
return ids
|
||||
}
|
||||
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 := coupleUserIds(c, 0)
|
||||
if len(ids) == 0 {
|
||||
photoTotal := int64(0)
|
||||
for _, album := range demoData("album") {
|
||||
photoTotal += store.ToInt(album["photo_count"])
|
||||
}
|
||||
return demoListResponse(c, "album", M{"photo_total": photoTotal})
|
||||
}
|
||||
ids := albumUserIds(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...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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_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])
|
||||
@@ -62,11 +70,12 @@ func albumAdd(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid, err := requireCoupleID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
uid := c.UID()
|
||||
var albumCouple any
|
||||
if cid != "" {
|
||||
albumCouple = cid
|
||||
}
|
||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,13 +85,13 @@ func albumAdd(c *httpapi.Ctx) error {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
title := c.InStr("title")
|
||||
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
actID := albumActivity(c, "album_create", "创建了相册「"+title+"」")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
|
||||
"thing2": "TA创建了新相册,快来一起看看吧 📸",
|
||||
@@ -97,10 +106,8 @@ func albumUpdate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,10 +137,7 @@ func albumDelete(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
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 {
|
||||
return c.Err(404, "相册不存在")
|
||||
@@ -151,8 +155,8 @@ func setAlbumCover(c *httpapi.Ctx) error {
|
||||
if c.Method != "POST" {
|
||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||
}
|
||||
ids := coupleUserIds(c, 0)
|
||||
albumID := store.ToInt(c.Form("album_id"))
|
||||
ids := albumUserIds(c)
|
||||
albumID := c.Form("album_id")
|
||||
var a store.Row
|
||||
if len(ids) == 2 {
|
||||
a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1])
|
||||
@@ -160,18 +164,21 @@ func setAlbumCover(c *httpapi.Ctx) error {
|
||||
if a == nil {
|
||||
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
|
||||
}
|
||||
spaceID := coupleSpaceID(c, c.CoupleID())
|
||||
fh := c.File("cover")
|
||||
if fh == nil {
|
||||
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
|
||||
}
|
||||
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
|
||||
res := up.HandleFor(fh, c.UID())
|
||||
if res.Path != "" {
|
||||
c.UploadResult = &res
|
||||
if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil {
|
||||
return err
|
||||
}
|
||||
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, "违规") {
|
||||
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
|
||||
}
|
||||
@@ -190,7 +197,7 @@ func photos(c *httpapi.Ctx) error {
|
||||
if albumID == "" {
|
||||
albumID = "0"
|
||||
}
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := albumUserIds(c)
|
||||
if len(ids) == 0 {
|
||||
return listResponse(c, nil, 0, nil)
|
||||
}
|
||||
@@ -214,19 +221,14 @@ func photoUpload(c *httpapi.Ctx) error {
|
||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||
}
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
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")
|
||||
if albumID == "" {
|
||||
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)
|
||||
}
|
||||
description := c.Form("description")
|
||||
@@ -234,33 +236,51 @@ func photoUpload(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uploaded := []string{}
|
||||
quotaExceeded := false
|
||||
errors := []string{}
|
||||
files := c.Files("photos")
|
||||
if len(files) == 0 {
|
||||
files = c.Files("file")
|
||||
}
|
||||
// 存储配额检查(照片是存储大头,超限引导付费)
|
||||
if len(files) == 0 {
|
||||
// 兼容某些安卓端直接传二进制:从原始请求体读取
|
||||
raw := c.Body()
|
||||
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 != "" {
|
||||
c.DB.Exec("INSERT INTO photos (album_id, path, description, created_by) VALUES (?, ?, ?, ?)", albumID, res.Path, description, uid)
|
||||
c.UploadResult = &res
|
||||
if _, err := c.DB.Exec("INSERT INTO photos (album_id,path,description,created_by) VALUES (?,?,?,?)", albumID, res.Path, description, uid); err != nil {
|
||||
errors = append(errors, err.Error())
|
||||
} else {
|
||||
uploaded = append(uploaded, res.Path)
|
||||
setFirstCover(c, albumID, res.Path)
|
||||
} else if strings.Contains(res.Error, "违规") {
|
||||
errors = append(errors, "所发布内容含违规信息")
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, res.Error)
|
||||
}
|
||||
}
|
||||
if len(uploaded) == 0 {
|
||||
if quotaExceeded {
|
||||
return c.ErrCode(403, "存储空间不足,请扩容后重试", ErrStorageQuotaExceeded)
|
||||
}
|
||||
return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived)
|
||||
}
|
||||
}
|
||||
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 != "" {
|
||||
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)
|
||||
c.UploadResult = &res
|
||||
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)
|
||||
setFirstCover(c, albumID, res.Path)
|
||||
} else {
|
||||
@@ -268,11 +288,14 @@ func photoUpload(c *httpapi.Ctx) error {
|
||||
}
|
||||
}
|
||||
if len(uploaded) == 0 && len(errors) > 0 {
|
||||
if quotaExceeded {
|
||||
return c.ErrCode(403, "存储空间不足,请扩容后重试", ErrStorageQuotaExceeded)
|
||||
}
|
||||
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
|
||||
}
|
||||
if len(uploaded) > 0 {
|
||||
actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
actID := albumActivity(c, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)))
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "相册有更新",
|
||||
"thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)),
|
||||
@@ -281,25 +304,26 @@ func photoUpload(c *httpapi.Ctx) error {
|
||||
}, "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 {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
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])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if photo != nil {
|
||||
albumID := photo.Int("album_id")
|
||||
up.Remove(photo.Str("path"))
|
||||
albumID := photo.Str("album_id")
|
||||
c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
|
||||
album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID)
|
||||
if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") {
|
||||
@@ -318,15 +342,11 @@ func photoAdd(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid, err := requireCoupleID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.InBool("album_id") || !c.InBool("path") {
|
||||
return c.Err(400, "参数不完整")
|
||||
}
|
||||
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, "相册不存在")
|
||||
}
|
||||
uid := c.UID()
|
||||
@@ -341,8 +361,8 @@ func photoAdd(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
setFirstCover(c, albumID, c.InStr("path"))
|
||||
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
actID := albumActivity(c, "photo_upload", "上传了照片")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "相册有更新",
|
||||
"thing2": "TA上传了新照片 📸",
|
||||
@@ -358,12 +378,10 @@ func photoUpdate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := c.InInt("id")
|
||||
if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
id := c.InTrim("id")
|
||||
if id == "" || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
|
||||
return c.Err(400, "参数不完整")
|
||||
}
|
||||
location, lat, lng := photoLocation(c)
|
||||
@@ -381,7 +399,7 @@ func photoUpdate(c *httpapi.Ctx) error {
|
||||
if err := checkTextOr400(c, strings.TrimSpace(location+" "+activity)); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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("aFile, "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("aPNG, 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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"past/internal/httpapi"
|
||||
"past/internal/store"
|
||||
"past/internal/wechat"
|
||||
)
|
||||
|
||||
func registerCheckin(r *httpapi.Router) {
|
||||
@@ -19,6 +20,7 @@ func registerCheckin(r *httpapi.Router) {
|
||||
r.Handle("checkin_delete", checkinDelete)
|
||||
r.Handle("smoking_info", smokingInfo)
|
||||
r.Handle("smoking_action", smokingAction)
|
||||
r.Handle("smoking_card_visible", smokingCardVisible)
|
||||
}
|
||||
|
||||
// weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一
|
||||
@@ -65,8 +67,8 @@ func periodDone(freq string, last string, has bool, today, monday, month string)
|
||||
}
|
||||
|
||||
// decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段
|
||||
func decorateCheckin(item store.Row, uid int64, withAvatar bool) {
|
||||
isCreator := uid == item.Int("created_by")
|
||||
func decorateCheckin(item store.Row, uid string, withAvatar bool) {
|
||||
isCreator := uid == item.Str("created_by")
|
||||
freq := store.ToStr(item["frequency"])
|
||||
if item.IsNull("frequency") {
|
||||
freq = "daily"
|
||||
@@ -154,7 +156,7 @@ func decorateCheckin(item store.Row, uid int64, withAvatar bool) {
|
||||
}
|
||||
|
||||
func checkinList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
if c.QHas("page") || c.QHas("page_size") {
|
||||
return demoListResponse(c, "checkin", M{"week_both_count": 0, "checkin_log_total": 0})
|
||||
@@ -172,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])
|
||||
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)
|
||||
}
|
||||
if c.QHas("page") || c.QHas("page_size") {
|
||||
@@ -211,8 +213,8 @@ func checkinAdd(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
// loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404)
|
||||
func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) {
|
||||
ids := coupleUserIds(c, 0)
|
||||
func loadCheckinItem(c *httpapi.Ctx, id string) (store.Row, error) {
|
||||
ids := coupleUserIds(c, "")
|
||||
var item store.Row
|
||||
if len(ids) == 2 {
|
||||
var err error
|
||||
@@ -235,15 +237,15 @@ func checkinDo(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
cid, uid := c.CoupleID(), c.UID()
|
||||
id := c.InInt("id")
|
||||
if id == 0 {
|
||||
id := c.InTrim("id")
|
||||
if id == "" {
|
||||
return c.Err(400, "缺少打卡ID")
|
||||
}
|
||||
item, err := loadCheckinItem(c, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
isCreator := uid == item.Int("created_by")
|
||||
isCreator := uid == item.Str("created_by")
|
||||
td, nowS := today(), now()
|
||||
freq := store.ToStr(item["frequency"])
|
||||
if item.IsNull("frequency") {
|
||||
@@ -299,15 +301,10 @@ func checkinDo(c *httpapi.Ctx) error {
|
||||
c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id)
|
||||
}
|
||||
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)
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "打卡「" + mbTruncate(title, 20) + "」",
|
||||
"thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak),
|
||||
"date3": td,
|
||||
"thing4": "点击查看详情",
|
||||
}, "pages/punch/punch", actID, uid)
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", wechat.CheckinMessage(title, store.ToStr(note), nowS), "pages/punch/punch", actID, uid)
|
||||
}
|
||||
return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ})
|
||||
}
|
||||
@@ -317,8 +314,8 @@ func checkinUncheck(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
id := c.InInt("id")
|
||||
if id == 0 {
|
||||
id := c.InTrim("id")
|
||||
if id == "" {
|
||||
return c.Err(400, "缺少打卡ID")
|
||||
}
|
||||
item, err := loadCheckinItem(c, id)
|
||||
@@ -326,7 +323,7 @@ func checkinUncheck(c *httpapi.Ctx) error {
|
||||
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 = ?"
|
||||
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 = ?"
|
||||
}
|
||||
if _, err := c.DB.Exec(sql, id); err != nil {
|
||||
@@ -336,12 +333,12 @@ func checkinUncheck(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func checkinDetail(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
return c.OK([]any{})
|
||||
}
|
||||
id := c.QInt("id")
|
||||
if id == 0 {
|
||||
id := c.Q("id")
|
||||
if 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])
|
||||
@@ -371,7 +368,7 @@ func checkinDetail(c *httpapi.Ctx) error {
|
||||
var total, myT, myW, myM, myY, pT, pW, pM, pY int64
|
||||
for _, row := range stats {
|
||||
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")
|
||||
} else {
|
||||
pT += row.Int("today_count")
|
||||
@@ -388,21 +385,24 @@ func checkinDetail(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func checkinStats(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
ids = []int64{c.UID(), 0}
|
||||
ids = []string{c.UID(), "00000000-0000-0000-0000-000000000000"}
|
||||
}
|
||||
td, ws := today(), weekMonday()
|
||||
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 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_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),
|
||||
"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_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_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 DATE(partner_checkin_time) = ?", ids[0], ids[1], td) + smokeCount(partner, td),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func checkinToggle(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
id := c.In("id")
|
||||
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
|
||||
if err := guardSmokingCheckin(c, c.InTrim("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid)
|
||||
@@ -451,7 +451,7 @@ func checkinDelete(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
id := c.In("id")
|
||||
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
|
||||
if err := guardSmokingCheckin(c, c.InTrim("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
@@ -26,6 +24,7 @@ var up *upload.Uploader
|
||||
// Register 注册全部 action
|
||||
func Register(r *httpapi.Router, uploader *upload.Uploader) {
|
||||
up = uploader
|
||||
r.SetFileStorage(uploader)
|
||||
registerUser(r)
|
||||
registerCouple(r)
|
||||
registerTimeline(r)
|
||||
@@ -40,6 +39,11 @@ func Register(r *httpapi.Router, uploader *upload.Uploader) {
|
||||
registerSubscribe(r)
|
||||
registerPet(r)
|
||||
registerAdmin(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") }
|
||||
@@ -55,6 +59,13 @@ func needLoginRecord(lastLoginAt string) bool {
|
||||
func today() string { return time.Now().Format("2006-01-02") }
|
||||
func daysAgo(n int) string { return time.Now().AddDate(0, 0, -n).Format("2006-01-02") }
|
||||
|
||||
// startOfToday 返回今天 00:00(本地时区)。姨姨每日标记只写「已确认」的日期,
|
||||
// 未来日期尚未确认经期量,不预先标记为有经期,避免把预估结束日误判为实际经期。
|
||||
func startOfToday() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// rows 保证列表非 nil(JSON 输出 [] 而非 null)
|
||||
func rows(r []store.Row) []store.Row {
|
||||
if r == nil {
|
||||
@@ -64,48 +75,38 @@ func rows(r []store.Row) []store.Row {
|
||||
}
|
||||
|
||||
// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id
|
||||
// generateUserId uses a PostgreSQL sequence; simultaneous registrations cannot collide.
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
var next int64
|
||||
if row != nil && row.Int("value") >= 520001 {
|
||||
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 row == nil {
|
||||
return "", fmt.Errorf("用户编号生成失败")
|
||||
}
|
||||
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 "", err
|
||||
}
|
||||
return fmt.Sprint(next), nil
|
||||
return row.Str("display_id"), nil
|
||||
}
|
||||
|
||||
// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT)
|
||||
func generateCoupleSpaceId(coupleID int64) string {
|
||||
return fmt.Sprintf("CP%06d", coupleID)
|
||||
func generateCoupleSpaceId(coupleID string) string {
|
||||
return "CP" + strings.ReplaceAll(coupleID, "-", "")
|
||||
}
|
||||
|
||||
// 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())",
|
||||
coupleID, userID, actionType, description, detail)
|
||||
return id
|
||||
}
|
||||
|
||||
// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空
|
||||
func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 {
|
||||
if coupleID == 0 {
|
||||
func coupleUserIds(c *httpapi.Ctx, coupleID string) []string {
|
||||
if coupleID == "" {
|
||||
coupleID = c.CoupleID()
|
||||
if coupleID == 0 {
|
||||
if coupleID == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -113,28 +114,28 @@ func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 {
|
||||
if row == 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
|
||||
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)
|
||||
if u == nil || u.Int("couple_id") == 0 {
|
||||
return 0
|
||||
if u == nil || u.Str("couple_id") == "" {
|
||||
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 {
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
if cp.Int("user1_id") == userID {
|
||||
return cp.Int("user2_id")
|
||||
if cp.Str("user1_id") == userID {
|
||||
return cp.Str("user2_id")
|
||||
}
|
||||
return cp.Int("user1_id")
|
||||
return cp.Str("user1_id")
|
||||
}
|
||||
|
||||
// userOpenId 对应 getUserOpenId($uid)
|
||||
func userOpenId(db store.Querier, uid int64) string {
|
||||
if uid == 0 {
|
||||
func userOpenId(db store.Querier, uid string) string {
|
||||
if uid == "" {
|
||||
return ""
|
||||
}
|
||||
row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid)
|
||||
@@ -145,7 +146,7 @@ func userOpenId(db store.Querier, uid int64) string {
|
||||
}
|
||||
|
||||
// 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 diaries WHERE couple_id = ?", cid)
|
||||
db.Exec("DELETE FROM songs WHERE couple_id = ?", cid)
|
||||
@@ -153,11 +154,7 @@ func executeUnbind(db store.Querier, cid int64) {
|
||||
db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid)
|
||||
albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
|
||||
for _, a := range albums {
|
||||
photos, _ := db.Query("SELECT path FROM photos WHERE album_id = ?", a.Int("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 photos WHERE album_id = ?", a.Str("id"))
|
||||
}
|
||||
db.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
|
||||
db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid)
|
||||
@@ -165,7 +162,7 @@ func executeUnbind(db store.Querier, cid int64) {
|
||||
if len(users) > 0 {
|
||||
ids := make([]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
ids = append(ids, u.Int("id"))
|
||||
ids = append(ids, u.Str("id"))
|
||||
}
|
||||
ph := placeholders(len(ids))
|
||||
args := append(append([]any{}, ids...), ids...)
|
||||
@@ -174,18 +171,11 @@ func executeUnbind(db store.Querier, cid int64) {
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// createDefaultAlbums 对应 createDefaultAlbums()
|
||||
func createDefaultAlbums(db store.Querier, coupleID, userID int64) {
|
||||
func createDefaultAlbums(db store.Querier, coupleID, userID string) {
|
||||
defaults := [][2]string{
|
||||
{"日常甜蜜", "我们的小日常"},
|
||||
{"旅行记录", "一起走过的风景"},
|
||||
@@ -422,3 +412,10 @@ func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any {
|
||||
|
||||
// randInt 对应 random_int(min, max)
|
||||
func randInt(min, max int) int { return min + rand.Intn(max-min+1) }
|
||||
|
||||
func nullableCoupleID(id string) any {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func generateInviteLink(c *httpapi.Ctx) error {
|
||||
}
|
||||
uid := c.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, "您已绑定情侣")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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, "您已绑定情侣")
|
||||
}
|
||||
if me != nil && me.Str("username") == target {
|
||||
@@ -137,13 +137,13 @@ func inviteByPhone(c *httpapi.Ctx) error {
|
||||
if t == nil {
|
||||
return c.Err(400, "该用户不存在")
|
||||
}
|
||||
if t.Int("couple_id") != 0 {
|
||||
if t.Str("couple_id") != "" {
|
||||
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, "已发送过邀请,等待对方回应")
|
||||
}
|
||||
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 c.OK(M{"success": true, "msg": "邀请已发送"})
|
||||
@@ -163,7 +163,7 @@ func invitePartner(c *httpapi.Ctx) error {
|
||||
return c.Err(400, "ID格式不正确,请输入6位数字ID")
|
||||
}
|
||||
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, "您已绑定情侣")
|
||||
}
|
||||
if me != nil && me.Str("user_id") == targetID {
|
||||
@@ -176,13 +176,13 @@ func invitePartner(c *httpapi.Ctx) error {
|
||||
if t == nil {
|
||||
return c.Err(400, "该ID不存在,请确认对方ID是否正确")
|
||||
}
|
||||
if t.Int("couple_id") != 0 {
|
||||
if t.Str("couple_id") != "" {
|
||||
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, "已发送过邀请,等待对方回应")
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -192,9 +192,9 @@ func invitePartner(c *httpapi.Ctx) error {
|
||||
func myInvitations(c *httpapi.Ctx) error {
|
||||
uid := c.UID()
|
||||
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
|
||||
var coupleID int64
|
||||
var coupleID string
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -212,32 +212,30 @@ func myInvitations(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2)
|
||||
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 {
|
||||
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 {
|
||||
return 0, err
|
||||
return "", err
|
||||
}
|
||||
if extra != nil {
|
||||
if err := extra(tx); err != nil {
|
||||
return 0, err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
return "", err
|
||||
}
|
||||
ensureUserDirs(user1)
|
||||
ensureUserDirs(user2)
|
||||
return coupleID, nil
|
||||
}
|
||||
|
||||
@@ -246,7 +244,7 @@ func respondInvitation(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
invID := c.InInt("invitation_id")
|
||||
invID := c.InTrim("invitation_id")
|
||||
accept := c.InBool("accept")
|
||||
inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid)
|
||||
if err != nil {
|
||||
@@ -255,10 +253,10 @@ func respondInvitation(c *httpapi.Ctx) error {
|
||||
if inv == nil {
|
||||
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)
|
||||
for _, r := range rs {
|
||||
if r.Int("couple_id") != 0 {
|
||||
if r.Str("couple_id") != "" {
|
||||
return c.Err(400, "已绑定情侣,无法接受")
|
||||
}
|
||||
}
|
||||
@@ -295,14 +293,14 @@ func acceptShareInvite(c *httpapi.Ctx) error {
|
||||
return c.Err(400, "缺少邀请人ID")
|
||||
}
|
||||
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, "您已绑定情侣")
|
||||
}
|
||||
inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID)
|
||||
if inviter == nil {
|
||||
return c.Err(400, "邀请人不存在")
|
||||
}
|
||||
if inviter.Int("couple_id") != 0 {
|
||||
if inviter.Str("couple_id") != "" {
|
||||
return c.Err(400, "邀请人已绑定情侣")
|
||||
}
|
||||
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") {
|
||||
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)
|
||||
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 {
|
||||
if existing == nil {
|
||||
@@ -341,7 +339,7 @@ func acceptShareInvite(c *httpapi.Ctx) error {
|
||||
func coupleInfo(c *httpapi.Ctx) error {
|
||||
cid := c.CoupleID()
|
||||
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)
|
||||
regDate := today()
|
||||
if user != nil {
|
||||
@@ -373,7 +371,7 @@ func coupleInfo(c *httpapi.Ctx) error {
|
||||
if info == nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -395,7 +393,7 @@ func coupleInfo(c *httpapi.Ctx) error {
|
||||
}
|
||||
var partner store.Row
|
||||
for _, p := range partners {
|
||||
if p.Int("id") != uid {
|
||||
if p.Str("id") != uid {
|
||||
partner = p
|
||||
}
|
||||
}
|
||||
@@ -429,7 +427,7 @@ func updateCoupleInfo(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
if cid == 0 {
|
||||
if cid == "" {
|
||||
return c.Err(400, "请先绑定情侣")
|
||||
}
|
||||
uid := c.UID()
|
||||
@@ -445,18 +443,18 @@ func updateCoupleInfo(c *httpapi.Ctx) error {
|
||||
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)
|
||||
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.
|
||||
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()
|
||||
if cid == 0 {
|
||||
if cid == "" {
|
||||
return c.Err(400, "未绑定情侣")
|
||||
}
|
||||
tx, err := c.DB.Begin()
|
||||
@@ -484,8 +482,8 @@ func unbindRequest(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := withBoundCouple(c, func(tx *store.Tx, cid int64) 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 {
|
||||
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<=(NOW() - INTERVAL '72 hour')", cid); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
agree := c.InBool("agree")
|
||||
err := withBoundCouple(c, func(tx *store.Tx, cid int64) 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())
|
||||
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>(NOW() - INTERVAL '72 hour') FOR UPDATE", cid, c.UID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -517,9 +515,9 @@ func unbindRespond(c *httpapi.Ctx) error {
|
||||
return c.Err(409, "没有有效的待确认申请")
|
||||
}
|
||||
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 {
|
||||
_, 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
|
||||
})
|
||||
@@ -532,15 +530,15 @@ func unbindCancel(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := withBoundCouple(c, func(tx *store.Tx, cid int64) 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())
|
||||
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>(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 {
|
||||
return err
|
||||
}
|
||||
if req == nil {
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
@@ -554,29 +552,29 @@ func unbindStatus(c *httpapi.Ctx) error {
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
uid := c.UID()
|
||||
if cid == 0 {
|
||||
cp, err := c.DB.QueryOne("SELECT id FROM couples WHERE user1_id=? OR user2_id=? ORDER BY id DESC LIMIT 1", uid, uid)
|
||||
if cid == "" {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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})
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if req == nil {
|
||||
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["data_deleted"] = 0
|
||||
if req.Str("status") == "completed" {
|
||||
@@ -588,7 +586,7 @@ func unbindStatus(c *httpapi.Ctx) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -604,7 +602,7 @@ func unbindCouple(c *httpapi.Ctx) error {
|
||||
if err := c.DB.AdvanceUnbindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.CoupleID() != 0 {
|
||||
if c.CoupleID() != "" {
|
||||
return c.Err(409, "冷静期结束后将自动解绑,不能提前解除")
|
||||
}
|
||||
return c.OK(M{"success": true})
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"past/internal/store"
|
||||
)
|
||||
|
||||
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义
|
||||
const mediaBase = "https://past.sundynix.cn/backend/"
|
||||
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://we.sundynix.cn/backend/'),MEDIA_BASE 未定义
|
||||
const mediaBase = "https://we.sundynix.cn/backend/"
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
@@ -31,7 +31,7 @@ func avatarURL(avatar string) string {
|
||||
if avatar == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(avatar, "http") {
|
||||
if strings.HasPrefix(avatar, "http") || strings.HasPrefix(avatar, "/api/files/") {
|
||||
return avatar
|
||||
}
|
||||
return mediaBase + avatar
|
||||
@@ -58,7 +58,7 @@ func registerDiary(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
func diaryList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := albumUserIds(c)
|
||||
if len(ids) == 0 {
|
||||
demo := demoData("diary")
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
cid, err := requireCoupleID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
uid := c.UID()
|
||||
diaryDate := diaryDateOrFallback(c.In("diary_date"), today())
|
||||
images := inOr(c, "images", []any{})
|
||||
@@ -129,7 +126,7 @@ func diaryAdd(c *httpapi.Ctx) error {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -145,7 +142,7 @@ func diaryAdd(c *httpapi.Ctx) error {
|
||||
desc += fmt.Sprintf(",上传了%d张图片", imgCount)
|
||||
}
|
||||
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 := "一篇新日记"
|
||||
if store.Truthy(titleS) {
|
||||
displayTitle = titleS
|
||||
@@ -166,10 +163,13 @@ func diaryUpdate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
ids := albumUserIds(c)
|
||||
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
|
||||
} else if item == nil {
|
||||
return c.Err(404, "日记不存在或无权编辑")
|
||||
}
|
||||
var err error
|
||||
images := inOr(c, "images", []any{})
|
||||
// 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期;
|
||||
// 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。
|
||||
@@ -195,10 +195,7 @@ func diaryDelete(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
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
|
||||
}
|
||||
@@ -214,14 +211,12 @@ func countOf(c *httpapi.Ctx, sql string, args ...any) int64 {
|
||||
}
|
||||
|
||||
func diaryDetail(c *httpapi.Ctx) error {
|
||||
id := c.QInt("id")
|
||||
if id == 0 {
|
||||
id := c.Q("id")
|
||||
if id == "" {
|
||||
return c.Err(400, "缺少日记ID")
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
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])
|
||||
if err != nil {
|
||||
@@ -243,14 +238,12 @@ func diaryDetail(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func diaryCommentList(c *httpapi.Ctx) error {
|
||||
diaryID := c.QInt("diary_id")
|
||||
if diaryID == 0 {
|
||||
diaryID := c.Q("diary_id")
|
||||
if diaryID == "" {
|
||||
return c.Err(400, "缺少日记ID")
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
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...)...)
|
||||
if err != nil {
|
||||
@@ -271,12 +264,12 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
diaryID := c.InInt("diary_id")
|
||||
diaryID := c.InTrim("diary_id")
|
||||
content := c.InTrim("content")
|
||||
if diaryID == 0 {
|
||||
if diaryID == "" {
|
||||
return c.Err(400, "缺少日记ID")
|
||||
}
|
||||
if !store.Truthy(content) {
|
||||
@@ -289,10 +282,10 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
|
||||
if diary == nil {
|
||||
return c.Err(404, "日记不存在")
|
||||
}
|
||||
parentID := c.InInt("parent_id")
|
||||
parentID := c.InTrim("parent_id")
|
||||
var replyToUserID any
|
||||
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 (?, ?, ?, ?, ?, ?, ?)",
|
||||
diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username"))
|
||||
@@ -306,14 +299,14 @@ func diaryCommentAdd(c *httpapi.Ctx) error {
|
||||
out = comment
|
||||
}
|
||||
// 通知对象:优先被回复的人,否则日记作者;自己评论自己不通知
|
||||
var notifyTo int64
|
||||
var notifyTo string
|
||||
if replyToUserID != nil {
|
||||
notifyTo = store.ToInt(replyToUserID)
|
||||
notifyTo = store.ToStr(replyToUserID)
|
||||
}
|
||||
if notifyTo == 0 {
|
||||
notifyTo = diary.Int("created_by")
|
||||
if notifyTo == "" {
|
||||
notifyTo = diary.Str("created_by")
|
||||
}
|
||||
if notifyTo != 0 && notifyTo != uid {
|
||||
if notifyTo != "" && notifyTo != uid {
|
||||
actID := logActivity(c.DB, cid, uid, "diary_comment_add", "评论了你的日记", "")
|
||||
c.App.Wechat.SendSubscribeMessage(notifyTo, "notify", map[string]string{
|
||||
"thing1": "日记有新评论 💬",
|
||||
@@ -330,11 +323,11 @@ func diaryCommentDelete(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
commentID := c.InInt("id")
|
||||
if commentID == 0 {
|
||||
commentID := c.InTrim("id")
|
||||
if commentID == "" {
|
||||
return c.Err(400, "缺少评论ID")
|
||||
}
|
||||
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},
|
||||
{"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},
|
||||
{"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)}}}}},
|
||||
{"comments", "/api/diaryCommentList?diary_id=9", "diary_comments", diaryCommentList, []any{int64(9), int64(1), int64(3), int64(20), int64(20)}, nil},
|
||||
} {
|
||||
|
||||
@@ -22,8 +22,12 @@ func registerBg(r *httpapi.Router) {
|
||||
// uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败)
|
||||
func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error {
|
||||
if res.Path != "" {
|
||||
c.UploadResult = &res
|
||||
return onSuccess(res.Path)
|
||||
}
|
||||
if res.Code == upload.ErrStorageQuotaExceeded {
|
||||
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
|
||||
}
|
||||
if strings.Contains(res.Error, "违规") {
|
||||
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
|
||||
}
|
||||
@@ -42,14 +46,13 @@ func uploadGeneric(c *httpapi.Ctx) error {
|
||||
return c.Err(400, "无效的上传目录")
|
||||
}
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
|
||||
}
|
||||
prefix := upload.UserFilePath(uid, subDir)
|
||||
var res *upload.Result
|
||||
for _, f := range []string{"file", "avatar", "photo", "image"} {
|
||||
if fh := c.File(f); fh != nil {
|
||||
r := up.Handle(fh, "", prefix)
|
||||
r := up.HandleFor(fh, c.UID())
|
||||
res = &r
|
||||
break
|
||||
}
|
||||
@@ -57,7 +60,7 @@ func uploadGeneric(c *httpapi.Ctx) error {
|
||||
if res == nil {
|
||||
// 兼容某些 Android 端:原始请求体
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -70,7 +73,7 @@ func uploadGeneric(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
return "", false
|
||||
}
|
||||
@@ -78,19 +81,26 @@ func settingValue(c *httpapi.Ctx, key string) (string, bool) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func bgKeys(cid int64) (string, string) {
|
||||
if cid != 0 {
|
||||
func bgKeys(cid string) (string, string) {
|
||||
if cid != "" {
|
||||
return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid)
|
||||
}
|
||||
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 {
|
||||
bgKey, verKey := bgKeys(c.CoupleID())
|
||||
bgKey, verKey := personalBgKeys(c)
|
||||
img, _ := settingValue(c, bgKey)
|
||||
ver, _ := settingValue(c, verKey)
|
||||
return c.OK(M{"image": img, "version": store.ToInt(ver)})
|
||||
@@ -100,12 +110,7 @@ func setBgImage(c *httpapi.Ctx) error {
|
||||
if c.Method != "POST" {
|
||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
if cid == 0 {
|
||||
return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound)
|
||||
}
|
||||
couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers")
|
||||
bgKey, verKey := bgKeys(cid)
|
||||
bgKey, verKey := personalBgKeys(c)
|
||||
nextVersion := func() int64 {
|
||||
if old, ok := settingValue(c, verKey); ok {
|
||||
return store.ToInt(old) + 1
|
||||
@@ -127,13 +132,17 @@ func setBgImage(c *httpapi.Ctx) error {
|
||||
}
|
||||
if fh != nil {
|
||||
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 {
|
||||
res := up.SaveRaw(raw, "bg_", "jpg", "", couplePath)
|
||||
res := up.SaveRawFor(raw, "jpg", c.UID())
|
||||
if res.Path != "" {
|
||||
c.UploadResult = &res
|
||||
return saveBg(res.Path, nextVersion())
|
||||
}
|
||||
if res.Code == upload.ErrStorageQuotaExceeded {
|
||||
return c.ErrCode(403, res.Error, ErrStorageQuotaExceeded)
|
||||
}
|
||||
if strings.Contains(res.Error, "违规") {
|
||||
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
|
||||
}
|
||||
@@ -146,15 +155,8 @@ func deleteBgImage(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid, err := requireCoupleID(c)
|
||||
if 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 {
|
||||
bgKey, verKey := personalBgKeys(c)
|
||||
if _, err := c.DB.Exec("DELETE FROM settings WHERE key = ? OR key = ?", bgKey, verKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(M{"success": true})
|
||||
@@ -168,7 +170,7 @@ func registerSite(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -235,7 +237,7 @@ func pageParams(c *httpapi.Ctx, defSize, maxSize int64) (page, pageSize, offset
|
||||
}
|
||||
|
||||
func activityLogList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
return c.OK([]any{})
|
||||
}
|
||||
@@ -255,13 +257,16 @@ func subscribeTemplate(c *httpapi.Ctx) error {
|
||||
}
|
||||
uid := c.UID()
|
||||
templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id")
|
||||
if templateType == "checkin" {
|
||||
templateType = "notify"
|
||||
}
|
||||
if !store.Truthy(templateType) || !store.Truthy(templateID) {
|
||||
return c.Err(400, "缺少参数")
|
||||
}
|
||||
if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID {
|
||||
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 c.OK(M{"success": true})
|
||||
@@ -272,10 +277,13 @@ func unsubscribeTemplate(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
templateType := c.InTrim("template_type")
|
||||
if templateType == "checkin" {
|
||||
templateType = "notify"
|
||||
}
|
||||
if !store.Truthy(templateType) {
|
||||
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 c.OK(M{"success": true})
|
||||
@@ -291,7 +299,9 @@ func mySubscribeTemplates(c *httpapi.Ctx) error {
|
||||
}
|
||||
out := M{}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc {
|
||||
|
||||
// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0
|
||||
func cidOrNil(c *httpapi.Ctx) any {
|
||||
if cid := c.CoupleID(); cid != 0 {
|
||||
if cid := c.CoupleID(); cid != "" {
|
||||
return cid
|
||||
}
|
||||
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)
|
||||
if row == nil {
|
||||
return 0
|
||||
@@ -67,7 +67,7 @@ func emptyPeriod(visible int64) M {
|
||||
}
|
||||
|
||||
type periodAccess struct {
|
||||
uid, cid, partnerID int64
|
||||
uid, cid, partnerID string
|
||||
visible, partnerVisible int64
|
||||
}
|
||||
|
||||
@@ -75,11 +75,11 @@ func currentPeriodAccess(c *httpapi.Ctx) periodAccess {
|
||||
uid := c.UID()
|
||||
cid := c.CoupleID()
|
||||
access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1}
|
||||
if cid != 0 {
|
||||
if cid != "" {
|
||||
access.partnerVisible = 0
|
||||
partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid)
|
||||
if partner != nil {
|
||||
access.partnerID = partner.Int("id")
|
||||
access.partnerID = partner.Str("id")
|
||||
access.partnerVisible = partner.Int("period_visible")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func periodList(c *httpapi.Ctx) error {
|
||||
// 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。
|
||||
var where string
|
||||
var args []any
|
||||
if access.cid != 0 {
|
||||
if access.cid != "" {
|
||||
where = "couple_id = ?"
|
||||
args = []any{access.cid}
|
||||
} else {
|
||||
@@ -144,7 +144,30 @@ func periodList(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(M{"cycles": rows(cycles), "daily": rows(daily), "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0})
|
||||
cycleRows := rows(cycles)
|
||||
dailyRows := rows(daily)
|
||||
// 经期结束以「最后一次标记有经期(period>0)的日期」为准:若周期记录的结束日期是预估的未来日期,
|
||||
// 则按实际最后有经期日期收敛,让前端能正确显示「已结束」。
|
||||
for _, cy := range cycleRows {
|
||||
start, end := cy.Str("start_date"), cy.Str("end_date")
|
||||
last := ""
|
||||
for _, d := range dailyRows {
|
||||
rd := d.Str("record_date")
|
||||
if rd < start || rd > end {
|
||||
continue
|
||||
}
|
||||
switch d.Str("period") {
|
||||
case "1", "2", "3":
|
||||
if rd > last {
|
||||
last = rd
|
||||
}
|
||||
}
|
||||
}
|
||||
if last != "" && last < end {
|
||||
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 != ""})
|
||||
}
|
||||
|
||||
func periodAdd(c *httpapi.Ctx) error {
|
||||
@@ -172,7 +195,8 @@ func periodAdd(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)
|
||||
// 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)。
|
||||
// 未来日期尚未确认经期量,不预先标记为有经期,避免把预估结束日误判为实际经期。
|
||||
start, err := parseDate(startDate)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -181,8 +205,8 @@ func periodAdd(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for d := start; !d.After(end); 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 {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
@@ -190,7 +214,7 @@ func periodAdd(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
if periodVisible(c, uid) != 0 {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "经期记录有更新",
|
||||
@@ -206,9 +230,9 @@ func periodAdd(c *httpapi.Ctx) error {
|
||||
|
||||
// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。
|
||||
// 未绑定情侣(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 {
|
||||
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())",
|
||||
coupleID, userID, actionType, description, "")
|
||||
@@ -220,8 +244,8 @@ func periodUpdate(c *httpapi.Ctx) error {
|
||||
}
|
||||
cid := cidOrNil(c)
|
||||
uid := c.UID()
|
||||
id := c.InInt("id")
|
||||
if id == 0 {
|
||||
id := c.InTrim("id")
|
||||
if id == "" {
|
||||
return c.Err(400, "缺少记录ID")
|
||||
}
|
||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
|
||||
@@ -292,7 +316,7 @@ func periodUpdate(c *httpapi.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) {
|
||||
date := d.Format("2006-01-02")
|
||||
if cid == nil {
|
||||
// MySQL 的 UNIQUE 索引允许多个 NULL;未绑定记录必须按用户和日期查找。
|
||||
@@ -307,7 +331,7 @@ func periodUpdate(c *httpapi.Ctx) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -323,14 +347,14 @@ func periodDelete(c *httpapi.Ctx) error {
|
||||
}
|
||||
cid := cidOrNil(c)
|
||||
uid := c.UID()
|
||||
id := c.InInt("id")
|
||||
id := c.InTrim("id")
|
||||
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
|
||||
cond, condArgs := coupleCond(cid)
|
||||
if cid == nil {
|
||||
cond += " AND user_id = ?"
|
||||
condArgs = append(condArgs, uid)
|
||||
}
|
||||
if id != 0 {
|
||||
if id != "" {
|
||||
delArgs := append([]any{id}, condArgs...)
|
||||
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
|
||||
return err
|
||||
@@ -355,7 +379,7 @@ func periodDelete(c *httpapi.Ctx) error {
|
||||
|
||||
func periodVisibleGet(c *httpapi.Ctx) error {
|
||||
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 {
|
||||
@@ -415,7 +439,7 @@ func periodDailySave(c *httpapi.Ctx) error {
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -434,12 +458,12 @@ func periodDailySave(c *httpapi.Ctx) error {
|
||||
}
|
||||
for _, cy := range cycles {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -505,7 +529,7 @@ func periodSexAdd(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
if periodVisible(c, uid) != 0 {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情",
|
||||
@@ -521,8 +545,8 @@ func periodSexDelete(c *httpapi.Ctx) error {
|
||||
}
|
||||
cid := cidOrNil(c)
|
||||
uid := c.UID()
|
||||
id := c.InInt("id")
|
||||
if id == 0 {
|
||||
id := c.InTrim("id")
|
||||
if id == "" {
|
||||
return c.Err(400, "缺少记录ID")
|
||||
}
|
||||
if cid == nil {
|
||||
|
||||
@@ -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()
|
||||
if cid == 0 {
|
||||
return false, uid, 0, nil
|
||||
if cid == "" {
|
||||
return false, uid, "", nil
|
||||
}
|
||||
couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid)
|
||||
if err != nil || couple == nil {
|
||||
return false, 0, 0, err
|
||||
return false, "", "", err
|
||||
}
|
||||
if couple.Int("user1_id") == uid {
|
||||
partner = couple.Int("user2_id")
|
||||
} else if couple.Int("user2_id") == uid {
|
||||
partner = couple.Int("user1_id")
|
||||
if couple.Str("user1_id") == uid {
|
||||
partner = couple.Str("user2_id")
|
||||
} else if couple.Str("user2_id") == uid {
|
||||
partner = couple.Str("user1_id")
|
||||
} 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
|
||||
}
|
||||
myCount, err := periodSourceCount(c.DB, uid)
|
||||
if err != nil {
|
||||
return false, 0, 0, err
|
||||
return false, "", "", err
|
||||
}
|
||||
partnerCount, err := periodSourceCount(c.DB, partner)
|
||||
if err != nil {
|
||||
return false, 0, 0, err
|
||||
return false, "", "", err
|
||||
}
|
||||
if myCount > 0 && partnerCount > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
func periodSourceCount(db store.Querier, uid int64) (int64, error) {
|
||||
func periodSourceCount(db store.Querier, uid string) (int64, error) {
|
||||
row, err := db.QueryOne(`SELECT
|
||||
(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) +
|
||||
@@ -59,7 +59,7 @@ func periodSourceCount(db store.Querier, uid int64) (int64, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
|
||||
func selectPeriodSource(c *httpapi.Ctx, keepUserID string) error {
|
||||
cid := c.CoupleID()
|
||||
if cid == 0 {
|
||||
if cid == "" {
|
||||
return c.Err(400, "未绑定伴侣")
|
||||
}
|
||||
tx, err := c.DB.Begin()
|
||||
@@ -85,18 +85,18 @@ func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
|
||||
if err != nil {
|
||||
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, "记录来源不是当前情侣成员")
|
||||
}
|
||||
if existing := couple.Int("period_source_user_id"); existing != 0 {
|
||||
if existing := couple.Str("period_source_user_id"); existing != "" {
|
||||
if existing == keepUserID {
|
||||
return nil
|
||||
}
|
||||
return c.Err(409, "姨姨记录来源已经选定")
|
||||
}
|
||||
deleteUserID := couple.Int("user1_id")
|
||||
deleteUserID := couple.Str("user1_id")
|
||||
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"} {
|
||||
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, 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 {
|
||||
return err
|
||||
}
|
||||
@@ -114,10 +114,10 @@ func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func periodSourceSelect(c *httpapi.Ctx) error {
|
||||
if err := requirePeriodAccess(c); err != nil {
|
||||
return err
|
||||
}
|
||||
keepUserID := c.InInt("user_id")
|
||||
keepUserID := c.InTrim("user_id")
|
||||
if err := selectPeriodSource(c, keepUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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.Header.Set("Authorization", "Bearer test")
|
||||
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
|
||||
}
|
||||
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 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: "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)
|
||||
if out.Code != 200 || s.Commits != 1 {
|
||||
|
||||
@@ -77,7 +77,7 @@ func consecutiveDoneDays(rows []store.Row) int64 {
|
||||
|
||||
// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra)
|
||||
// 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
|
||||
rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID)
|
||||
unlocked := map[string]bool{}
|
||||
@@ -126,28 +126,27 @@ func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int6
|
||||
if len(toUnlock) == 0 {
|
||||
return
|
||||
}
|
||||
// pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500,
|
||||
// Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。
|
||||
// 系统动态使用 NULL user_id,保留 GORM 公共模型与逻辑删除规则。
|
||||
db.ExecNoFK(func(q store.Querier) error {
|
||||
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)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0)
|
||||
func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) {
|
||||
// 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 string) (string, string) {
|
||||
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)
|
||||
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) {
|
||||
rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID)
|
||||
var u1, u2 any = int64(0), int64(0)
|
||||
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID string) (any, any) {
|
||||
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 = "", ""
|
||||
if len(rs) > 0 {
|
||||
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 {
|
||||
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_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", 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 (?, NULL, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣")
|
||||
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)
|
||||
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)
|
||||
u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw)
|
||||
u1, u2 := store.ToStr(u1raw), store.ToStr(u2raw)
|
||||
expForNext := pet.Int("level") * 100
|
||||
var expProgress any = 0
|
||||
if expForNext > 0 {
|
||||
@@ -248,7 +247,7 @@ func petAdopt(c *httpapi.Ctx) error {
|
||||
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)
|
||||
checkPetAchievements(c, coupleID, 1, 1, 100, false)
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
// 使用活动ID记录发送事件,供发送日志和去重查询使用。
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕",
|
||||
@@ -315,13 +314,13 @@ func petDailyAction(c *httpapi.Ctx) error {
|
||||
if daily.Int(field) == 1 {
|
||||
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
|
||||
}
|
||||
allDone := true
|
||||
for _, f := range petDailyFields {
|
||||
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 {
|
||||
allDone = false
|
||||
break
|
||||
@@ -358,20 +357,20 @@ func petDailyAction(c *httpapi.Ctx) error {
|
||||
stageOut = newStage
|
||||
}
|
||||
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
|
||||
}
|
||||
msg := petActionMessages[action]
|
||||
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg)
|
||||
if allDone {
|
||||
c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("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 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.Str("id"))
|
||||
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", "今日宠物所有任务完成!", "")
|
||||
}
|
||||
checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false)
|
||||
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
|
||||
if allDone {
|
||||
notifyMsg = msg + ",今日所有任务完成!🎉"
|
||||
@@ -467,7 +466,7 @@ func petRename(c *httpapi.Ctx) error {
|
||||
level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness")
|
||||
}
|
||||
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{
|
||||
"thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕",
|
||||
}, "pages/pet/pet", actID, uid)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -54,8 +54,14 @@ func smokingDelta(kind string, quantity, stock int64) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func smokingSnapshot(c *httpapi.Ctx, uid int64) (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(), "available": true}
|
||||
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}
|
||||
// 抽烟卡片显示/隐藏偏好:始终返回“当前登录用户”自己的设置(与查看对象无关),前端缓存失效后仍能恢复。
|
||||
if me, err := c.DB.QueryOne("SELECT smoking_card_visible FROM users WHERE id = ?", c.UID()); err != nil {
|
||||
return nil, err
|
||||
} else if me != nil {
|
||||
result["card_visible"] = me.Int("smoking_card_visible")
|
||||
}
|
||||
owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,13 +81,13 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
|
||||
if account == nil {
|
||||
return result, nil
|
||||
}
|
||||
aid := account.Int("id")
|
||||
aid := account.Str("id")
|
||||
result["stock"] = account.Int("stock")
|
||||
summary, err := c.DB.QueryOne(`SELECT
|
||||
COALESCE(SUM(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(SUM(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')),0) AS total_count,
|
||||
COALESCE(COUNT(*) FILTER (WHERE 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 >= ?),0) AS week_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 month_purchase_cents,
|
||||
MAX(CASE WHEN kind IN ('consume','receive_smoke') THEN created_at ELSE NULL END) AS last_time
|
||||
@@ -100,13 +106,13 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
|
||||
page, size, offset := pageParams(c, 20, 100)
|
||||
result["logs_page"] = page
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
result["logs"] = rows(logs)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,8 +122,8 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
|
||||
|
||||
func smokingInfo(c *httpapi.Ctx) error {
|
||||
uid := c.UID()
|
||||
requested := c.QInt("user_id")
|
||||
if requested == 0 {
|
||||
requested := c.Q("user_id")
|
||||
if requested == "" || requested == "0" {
|
||||
requested = uid
|
||||
}
|
||||
if requested != uid && requested != partnerUserId(c.DB, uid) {
|
||||
@@ -130,6 +136,22 @@ func smokingInfo(c *httpapi.Ctx) error {
|
||||
return c.OK(info)
|
||||
}
|
||||
|
||||
// smokingCardVisible 保存当前用户对“抽烟卡片”的显示/隐藏偏好(1=显示 0=隐藏),
|
||||
// 服务端持久化后,前端本地缓存失效时仍可恢复该设置。
|
||||
func smokingCardVisible(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
visible := c.InInt("visible")
|
||||
if visible != 0 && visible != 1 {
|
||||
return c.Err(400, "visible 必须为 0 或 1")
|
||||
}
|
||||
if _, err := c.DB.Exec("UPDATE users SET smoking_card_visible = ? WHERE id = ?", visible, c.UID()); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(M{"success": true, "card_visible": visible})
|
||||
}
|
||||
|
||||
func smokingAction(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
@@ -146,9 +168,13 @@ func smokingAction(c *httpapi.Ctx) error {
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
amount, amountErr := optionalInt("amount_cents")
|
||||
originalID, originalErr := optionalInt("original_id")
|
||||
if quantityErr != nil || amountErr != nil || originalErr != nil || (kind != "purchase" && amount != 0) || (kind != "undo" && originalID != 0) || (kind == "undo" && (originalID <= 0 || quantity != 0)) {
|
||||
return c.Err(400, "数量、金额和记录标识必须是有效整数")
|
||||
originalID := c.InTrim("original_id")
|
||||
originalErr := error(nil)
|
||||
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 {
|
||||
return c.Err(400, "参数不正确")
|
||||
@@ -170,7 +196,7 @@ func smokingAction(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid)
|
||||
@@ -180,7 +206,7 @@ func smokingAction(c *httpapi.Ctx) error {
|
||||
if account == nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -249,7 +275,7 @@ func smokingAction(c *httpapi.Ctx) error {
|
||||
smokeDesc = "TA 记录收到一支烟"
|
||||
}
|
||||
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, "")
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "戒烟打卡",
|
||||
@@ -261,8 +287,8 @@ func smokingAction(c *httpapi.Ctx) error {
|
||||
}
|
||||
return c.OK(M{"success": true, "info": info})
|
||||
}
|
||||
func nullableID(id int64) any {
|
||||
if id == 0 {
|
||||
func nullableID(id string) any {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) {
|
||||
}
|
||||
steps := []testdb.Step{
|
||||
uidStep(1),
|
||||
{Match: "SELECT smoking_card_visible FROM users", Columns: []string{"smoking_card_visible"}, Rows: [][]driver.Value{{int64(1)}}},
|
||||
{Match: "SELECT nickname, username", Columns: []string{"nickname", "username"}, Rows: [][]driver.Value{{"TA", "partner"}}},
|
||||
{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(10)}}},
|
||||
{Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(2)}}},
|
||||
@@ -36,12 +37,12 @@ func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) {
|
||||
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"}}},
|
||||
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: "GROUP BY DATE_FORMAT", Columns: []string{"day", "count"}},
|
||||
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 TO_CHAR", Columns: []string{"day", "count"}},
|
||||
)
|
||||
}
|
||||
c, _, _ := permissionCtx(t, "GET", "/api/smokingInfo?user_id=2", "", steps)
|
||||
result, err := smokingSnapshot(c, 2)
|
||||
result, err := smokingSnapshot(c, "2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func registerSongs(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
func songsList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
return demoListResponse(c, "songs", nil)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func songsAdd(c *httpapi.Ctx) error {
|
||||
}
|
||||
name, artist := c.InStr("song_name"), c.InStr("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{
|
||||
"thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕",
|
||||
}, "pages/lovewords/lovewords", actID, uid)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"past/internal/model"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"past/internal/httpapi"
|
||||
"past/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
settingFreeMB = "storage_free_mb"
|
||||
settingMonthPrice = "storage_plan_month_price"
|
||||
settingYearPrice = "storage_plan_year_price"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFreeMB int64 = 200
|
||||
defaultMonthPrice int64 = 500
|
||||
defaultYearPrice int64 = 2990
|
||||
)
|
||||
|
||||
// ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。
|
||||
const ErrStorageQuotaExceeded = 10001
|
||||
|
||||
func registerStorage(r *httpapi.Router) {
|
||||
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_order_create", storageOrderCreate)
|
||||
r.Handle("admin_storage_settings", adminStorageSettings)
|
||||
r.Handle("admin_storage_users", adminStorageUsers)
|
||||
r.Handle("admin_storage_user_update", adminStorageUserUpdate)
|
||||
r.Handle("admin_storage_orders", adminStorageOrders)
|
||||
}
|
||||
|
||||
func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 {
|
||||
row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") || row.Str("value") == "" {
|
||||
return def
|
||||
}
|
||||
return row.Int("value")
|
||||
}
|
||||
|
||||
// Count deduplicated MinIO objects, not directories on the API server.
|
||||
func allStorageUsedBytes() int64 {
|
||||
if up == nil || up.DB == nil {
|
||||
return 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func storageVipUserIDs(c *httpapi.Ctx, uid string) []string {
|
||||
ids := []string{uid}
|
||||
if p := partnerUserId(c.DB, uid); p != "" {
|
||||
ids = append(ids, p)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func isStorageVip(c *httpapi.Ctx, uid string) bool {
|
||||
a, err := store.StorageSpace(c.DB.ORM, uid)
|
||||
return err == nil && a.IsVip
|
||||
}
|
||||
func storageVipExpireAt(c *httpapi.Ctx, uid string) string {
|
||||
a, err := store.StorageSpace(c.DB.ORM, uid)
|
||||
if err == nil && a.ExpiresAt != nil {
|
||||
return a.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// storageQuotaMB 返回用户实际可用的免费存储容量(MB):
|
||||
// 优先使用后台手动设置的自定义容量,否则回退到全局默认免费额度。
|
||||
func storageQuotaMB(c *httpapi.Ctx, uid string) int64 {
|
||||
if uid != "" {
|
||||
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 storageSettingInt(c, settingFreeMB, defaultFreeMB)
|
||||
}
|
||||
|
||||
func storageInfo(c *httpapi.Ctx) error {
|
||||
uid := c.UID()
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
coupleID := c.CoupleID()
|
||||
freeMB := storageQuotaMB(c, uid)
|
||||
plans, err := storageCatalog(c.DB.ORM, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowance, err := store.StorageSpace(c.DB.ORM, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := M{
|
||||
"pay_provider": "wechat",
|
||||
"used_bytes": storageUsedBytes(c, uid, coupleID),
|
||||
"free_mb": freeMB,
|
||||
"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,
|
||||
"plans": plans,
|
||||
}
|
||||
if allowance.ExpiresAt != nil {
|
||||
resp["vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
func storageOrderCreate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.UID() == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
if !c.App.Cfg.WechatPay.Enabled {
|
||||
return c.Err(503, "微信支付服务未启用")
|
||||
}
|
||||
var plan model.StoragePlan
|
||||
r := c.DB.ORM.Where("key=? AND enabled=?", c.InTrim("plan_key"), true).Find(&plan)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected == 0 {
|
||||
return c.Err(400, "套餐已下架或不存在,请刷新")
|
||||
}
|
||||
if c.InHas("expected_price_cents") && c.InInt("expected_price_cents") != plan.PriceCents {
|
||||
return c.Err(409, "套餐价格已变更,请刷新后重新购买")
|
||||
}
|
||||
if value := c.InTrim("expected_plan_version"); value != "" {
|
||||
v, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || !v.Equal(plan.UpdatedAt) {
|
||||
return c.Err(409, "套餐配置已变更,请刷新后重新购买")
|
||||
}
|
||||
}
|
||||
return storageWechatOrderCreate(c, plan)
|
||||
|
||||
}
|
||||
|
||||
func adminStorageSettings(c *httpapi.Ctx) error {
|
||||
if c.Method == "GET" {
|
||||
var plans []model.StoragePlan
|
||||
var revision, free int64
|
||||
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; e != nil {
|
||||
return e
|
||||
}
|
||||
var e error
|
||||
plans, e = storageCatalog(tx, false)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return saveStorageCatalog(c)
|
||||
}
|
||||
|
||||
// storagePlanLabel 套餐中文名(后台展示用)
|
||||
func storagePlanLabel(plan string) string {
|
||||
switch plan {
|
||||
case "month":
|
||||
return "包月无限空间"
|
||||
case "year":
|
||||
return "包年无限空间"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// adminStorageUsers 存储用户管理:拉取全部用户,计算已用空间,支持搜索/筛选/排序。
|
||||
// sort: used_desc(默认,按已用空间从大到小)/ used_asc / expire / newest / last_login
|
||||
func adminStorageUsers(c *httpapi.Ctx) error {
|
||||
search := strings.TrimSpace(c.Q("search"))
|
||||
vip := strings.TrimSpace(c.Q("vip"))
|
||||
if !c.QHas("vip") {
|
||||
vip = "all"
|
||||
}
|
||||
sortKey := strings.TrimSpace(c.Q("sort"))
|
||||
if !c.QHas("sort") {
|
||||
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_name, storage_plan_started_at, storage_vip_expire_at FROM users"
|
||||
var where []string
|
||||
var params []any
|
||||
if vip == "vip" {
|
||||
where = append(where, "storage_vip_expire_at > NOW()")
|
||||
} else if vip == "free" {
|
||||
where = append(where, "(storage_vip_expire_at IS NULL OR storage_vip_expire_at <= NOW())")
|
||||
}
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
if store.ValidUUID(search) {
|
||||
where = append(where, "(id = ? OR nickname LIKE ? OR username 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 {
|
||||
where = append(where, "(nickname LIKE ? OR username LIKE ?)")
|
||||
params = append(params, like, like)
|
||||
}
|
||||
}
|
||||
if len(where) > 0 {
|
||||
sql += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
rs, err := c.DB.Query(sql, params...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
today := now()
|
||||
out := make([]store.Row, 0, len(rs))
|
||||
for _, u := range rs {
|
||||
uid, cid := u.Str("id"), u.Str("couple_id")
|
||||
expire := u.Str("storage_vip_expire_at")
|
||||
quota := u.Int("storage_quota_mb")
|
||||
u["used_bytes"] = storageUsedBytes(c, uid, cid)
|
||||
u["storage_vip"] = expire != "" && expire > today
|
||||
if quota <= 0 {
|
||||
quota = storageSettingInt(c, settingFreeMB, defaultFreeMB)
|
||||
}
|
||||
u["free_mb"] = quota
|
||||
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)
|
||||
}
|
||||
switch sortKey {
|
||||
case "used_asc":
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Int("used_bytes") < out[j].Int("used_bytes") })
|
||||
case "expire":
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ie, je := out[i].Str("storage_vip_expire_at"), out[j].Str("storage_vip_expire_at")
|
||||
if ie == je {
|
||||
return out[i].Int("used_bytes") > out[j].Int("used_bytes")
|
||||
}
|
||||
return ie > je
|
||||
})
|
||||
case "last_login":
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Str("last_login_at") > out[j].Str("last_login_at") })
|
||||
case "newest":
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Str("created_at") > out[j].Str("created_at") })
|
||||
default: // used_desc
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Int("used_bytes") > out[j].Int("used_bytes") })
|
||||
}
|
||||
return c.OK(paged(c, out))
|
||||
}
|
||||
|
||||
// adminStorageUserUpdate 手动修改用户存储容量 / 套餐 / 到期时间。
|
||||
func adminStorageUserUpdate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
uid := c.InTrim("user_id")
|
||||
if !store.ValidUUID(uid) {
|
||||
return c.Err(400, "用户ID无效")
|
||||
}
|
||||
quota := c.InInt("quota_mb")
|
||||
if quota < 0 || quota > 104857600 {
|
||||
return c.Err(400, "容量无效")
|
||||
}
|
||||
key := c.InTrim("plan_key")
|
||||
var plan model.StoragePlan
|
||||
if c.InHas("plan_key") && key != "free" {
|
||||
r := c.DB.ORM.Where("key=?", key).Find(&plan)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
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
|
||||
}
|
||||
if c.InHas("quota_mb") {
|
||||
if err := tx.Model(&user).Update("storage_quota_mb", quota).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.InHas("plan_key") {
|
||||
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
|
||||
}
|
||||
} else {
|
||||
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 tx.Model(&user).Update("storage_vip_expire_at", expiry).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(M{"success": true})
|
||||
}
|
||||
|
||||
// adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。
|
||||
func adminStorageOrders(c *httpapi.Ctx) error {
|
||||
search := strings.TrimSpace(c.Q("search"))
|
||||
status := strings.TrimSpace(c.Q("status"))
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
var where []string
|
||||
var params []any
|
||||
if status != "all" {
|
||||
where = append(where, "o.status = ?")
|
||||
params = append(params, status)
|
||||
}
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
if store.ValidUUID(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 {
|
||||
where = append(where, "(o.out_trade_no = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
|
||||
params = append(params, search, like, like)
|
||||
}
|
||||
}
|
||||
|
||||
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.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`
|
||||
if len(where) > 0 {
|
||||
sql += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
sql += " ORDER BY o.created_at DESC, o.id DESC"
|
||||
rs, err := c.DB.Query(sql, params...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range rs {
|
||||
o["plan_label"] = o.Str("plan_name")
|
||||
if o.Str("plan_label") == "" {
|
||||
o["plan_label"] = storagePlanLabel(o.Str("plan_key"))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -16,8 +16,8 @@ func registerTimeline(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
// requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400);
|
||||
func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) {
|
||||
ids := coupleUserIds(c, 0)
|
||||
func requireCoupleUserIds(c *httpapi.Ctx) ([]string, error) {
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
return nil, c.Err(400, "请先绑定情侣")
|
||||
}
|
||||
@@ -25,10 +25,10 @@ func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
if cid == 0 {
|
||||
return 0, c.Err(400, "请先绑定情侣")
|
||||
if cid == "" {
|
||||
return "", c.Err(400, "请先绑定情侣")
|
||||
}
|
||||
return cid, nil
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func checkTextOr400(c *httpapi.Ctx, text string) error {
|
||||
}
|
||||
|
||||
func timelineList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := albumUserIds(c)
|
||||
if len(ids) == 0 {
|
||||
return demoListResponse(c, "timeline", nil)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func timelineList(c *httpapi.Ctx) error {
|
||||
it["images"] = jsonDecodeList(it["images"])
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -66,23 +66,20 @@ func timelineAdd(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
cid, err := requireCoupleID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
uid := c.UID()
|
||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := c.InStr("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{
|
||||
"thing1": "记忆日更新",
|
||||
"thing2": "TA记录了「" + mbTruncate(title, 20) + "」",
|
||||
@@ -97,10 +94,13 @@ func timelineUpdate(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
ids := albumUserIds(c)
|
||||
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
|
||||
} 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 {
|
||||
return err
|
||||
}
|
||||
@@ -117,10 +117,7 @@ func timelineDelete(c *httpapi.Ctx) error {
|
||||
if err := c.RequirePOST(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
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
|
||||
}
|
||||
@@ -131,10 +128,8 @@ func timelineDetail(c *httpapi.Ctx) error {
|
||||
if err := c.RequireGET(); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := requireCoupleUserIds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := albumUserIds(c)
|
||||
var err error
|
||||
id := c.Q("id")
|
||||
if id == "" || id == "0" {
|
||||
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})
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -45,14 +43,6 @@ func last4(s string) string {
|
||||
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) {
|
||||
r.Handle("register", register)
|
||||
r.Handle("register_by_invite", registerByInvite)
|
||||
@@ -151,7 +141,7 @@ func registerByInvite(c *httpapi.Ctx) error {
|
||||
if link == nil {
|
||||
return c.Err(400, "邀请链接无效或已使用")
|
||||
}
|
||||
if link.Int("inviter_couple_id") != 0 {
|
||||
if link.Str("inviter_couple_id") != "" {
|
||||
return c.Err(400, "邀请人已绑定情侣")
|
||||
}
|
||||
displayID, err := generateUserId(c.DB)
|
||||
@@ -172,7 +162,7 @@ func registerByInvite(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -222,14 +210,14 @@ func login(c *httpapi.Ctx) error {
|
||||
return c.Err(401, "用户名或密码错误")
|
||||
}
|
||||
token := httpapi.NewToken()
|
||||
uid := user.Int("id")
|
||||
uid := user.Str("id")
|
||||
c.SaveUserToken(uid, token)
|
||||
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
|
||||
if loc := resolveLoginLocation(c, ""); loc != "" {
|
||||
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
|
||||
}
|
||||
if user.Int("couple_id") != 0 {
|
||||
logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "")
|
||||
if user.Str("couple_id") != "" {
|
||||
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"],
|
||||
"couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token})
|
||||
@@ -243,7 +231,7 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
if code == "" {
|
||||
return c.Err(400, "缺少登录凭证")
|
||||
}
|
||||
openid, wxErr, available := c.App.Wechat.Jscode2Session(code)
|
||||
openid, sessionKey, wxErr, available := c.App.Wechat.Jscode2Session(code)
|
||||
if !available {
|
||||
return c.Err(500, "微信登录服务暂不可用")
|
||||
}
|
||||
@@ -258,10 +246,14 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
if user != nil {
|
||||
uid := user.Int("id")
|
||||
uid := user.Str("id")
|
||||
token := httpapi.NewToken()
|
||||
c.SaveUserToken(uid, token)
|
||||
if sessionKey != "" {
|
||||
c.DB.Exec("UPDATE users SET last_login_at = NOW(), session_key = ? WHERE id = ?", sessionKey, uid)
|
||||
} else {
|
||||
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
|
||||
}
|
||||
region := c.InTrim("region")
|
||||
if region != "" && user.Str("region") == "" {
|
||||
c.DB.Exec("UPDATE users SET region = ? WHERE id = ?", region, uid)
|
||||
@@ -270,9 +262,9 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
if loc := resolveLoginLocation(c, region); loc != "" {
|
||||
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", "登录了小程序", "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
nick := user.Str("nickname")
|
||||
if nick == "" {
|
||||
nick = "你的TA"
|
||||
@@ -287,6 +279,7 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
}
|
||||
return c.OK(M{
|
||||
"success": true, "is_new": false,
|
||||
"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"],
|
||||
"avatar": user.Str("avatar"), "gender": user.Str("gender"),
|
||||
@@ -300,8 +293,8 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
}
|
||||
region := c.InTrim("region")
|
||||
defaultNick := "用户" + last4(displayID)
|
||||
newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region)
|
||||
newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region, session_key) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region, sessionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -313,6 +306,7 @@ func wechatLogin(c *httpapi.Ctx) error {
|
||||
c.SaveUserToken(newID, token)
|
||||
return c.OK(M{
|
||||
"success": true, "is_new": true,
|
||||
"data_revision": c.DataRevisionFor(fmt.Sprint(newID), ""),
|
||||
"user_id": fmt.Sprint(newID), "display_id": displayID,
|
||||
"couple_id": nil, "nickname": defaultNick,
|
||||
"avatar": "", "gender": "", "birthday": "", "region": region,
|
||||
@@ -326,27 +320,27 @@ func checkAuth(c *httpapi.Ctx) error {
|
||||
}
|
||||
c.CoupleID()
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.OK(M{"logged_in": false})
|
||||
}
|
||||
// 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同
|
||||
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")) {
|
||||
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
|
||||
if user.Int("couple_id") != 0 {
|
||||
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)
|
||||
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.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 {
|
||||
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 {
|
||||
if uid := c.UID(); uid != 0 {
|
||||
if uid := c.UID(); uid != "" {
|
||||
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
|
||||
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
|
||||
}
|
||||
@@ -362,8 +356,8 @@ func myInfo(c *httpapi.Ctx) error {
|
||||
if user == nil {
|
||||
return c.OK(false)
|
||||
}
|
||||
if user.Int("couple_id") != 0 {
|
||||
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)
|
||||
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.Str("couple_id"), uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -432,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 {
|
||||
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, "、"), "")
|
||||
}
|
||||
}
|
||||
@@ -471,7 +465,7 @@ func updateLocation(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if partner != nil && lat != 0 && lng != 0 {
|
||||
distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"]))
|
||||
@@ -524,12 +518,12 @@ func updateIPLocation(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if user == nil || user.Int("couple_id") == 0 {
|
||||
if user == nil || user.Str("couple_id") == "" {
|
||||
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 {
|
||||
return c.Err(400, "无法修改此用户信息")
|
||||
}
|
||||
@@ -541,7 +535,7 @@ func updatePartnerProfile(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
partnerID := c.InInt("partner_id")
|
||||
partnerID := c.InTrim("partner_id")
|
||||
nickname := c.InTrim("nickname")
|
||||
avatar := c.InStr("avatar")
|
||||
gender := c.InTrim("gender")
|
||||
@@ -585,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 {
|
||||
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, "、"), "")
|
||||
}
|
||||
}
|
||||
@@ -593,12 +587,7 @@ func updatePartnerProfile(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file
|
||||
func doAvatarUpload(c *httpapi.Ctx, targetUID int64) 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")
|
||||
}
|
||||
func doAvatarUpload(c *httpapi.Ctx, targetUID string) error {
|
||||
fh := c.File("avatar")
|
||||
if fh == nil {
|
||||
fh = c.File("file")
|
||||
@@ -606,13 +595,17 @@ func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error {
|
||||
if fh == nil {
|
||||
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
|
||||
}
|
||||
res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath))
|
||||
res := up.HandleFor(fh, targetUID)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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, "违规") {
|
||||
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
|
||||
}
|
||||
@@ -635,7 +628,7 @@ func uploadPartnerAvatar(c *httpapi.Ctx) error {
|
||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||
}
|
||||
uid := c.UID()
|
||||
partnerID := store.ToInt(c.Form("partner_id"))
|
||||
partnerID := c.Form("partner_id")
|
||||
if err := verifyPartner(c, uid, partnerID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -25,39 +25,39 @@ func (c *Ctx) Token() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回 0。
|
||||
func (c *Ctx) UID() int64 {
|
||||
// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回空字符串。
|
||||
func (c *Ctx) UID() string {
|
||||
if c.uidResolved {
|
||||
return c.uid
|
||||
}
|
||||
c.uidResolved = true
|
||||
tok := c.Token()
|
||||
if tok == "" {
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok)
|
||||
if err != nil || row == nil {
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
c.uid = row.Int("user_id")
|
||||
c.uid = row.Str("user_id")
|
||||
return c.uid
|
||||
}
|
||||
|
||||
// CoupleID 对应 getCoupleId();无则返回 0
|
||||
func (c *Ctx) CoupleID() int64 {
|
||||
// CoupleID 对应 getCoupleId();无则返回空字符串
|
||||
func (c *Ctx) CoupleID() string {
|
||||
if c.coupleResolved {
|
||||
return c.coupleID
|
||||
}
|
||||
c.coupleResolved = true
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
return 0
|
||||
if uid == "" {
|
||||
return ""
|
||||
}
|
||||
row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
|
||||
if row == nil {
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
c.coupleID = row.Int("couple_id")
|
||||
c.coupleID = row.Str("couple_id")
|
||||
return c.coupleID
|
||||
}
|
||||
|
||||
@@ -71,15 +71,15 @@ func NewToken() string {
|
||||
// SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。
|
||||
// PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录);
|
||||
// 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("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 分钟去重的登录记录
|
||||
func (c *Ctx) requireUserLogin() error {
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
if err := c.DB.AdvanceUnbindings(); err != nil {
|
||||
@@ -92,6 +92,7 @@ func (c *Ctx) requireUserLogin() error {
|
||||
if user == nil {
|
||||
return c.Err(404, "用户不存在")
|
||||
}
|
||||
c.G.Set("revisionCoupleID", user.Str("couple_id"))
|
||||
needRecord := false
|
||||
if user.IsNull("last_login_at") || user.Str("last_login_at") == "" {
|
||||
needRecord = true
|
||||
@@ -100,8 +101,8 @@ func (c *Ctx) requireUserLogin() error {
|
||||
}
|
||||
if needRecord {
|
||||
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
|
||||
if user.Int("couple_id") != 0 {
|
||||
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)
|
||||
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.Str("couple_id"), uid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -113,7 +114,7 @@ const adminCookieName = "PHPSESSID"
|
||||
const adminSessionTTL = 12 * time.Hour
|
||||
|
||||
type adminSession struct {
|
||||
AdminID int64
|
||||
AdminID string
|
||||
Username string
|
||||
Expires time.Time
|
||||
}
|
||||
@@ -162,7 +163,7 @@ func (c *Ctx) AdminSession() *adminSession {
|
||||
}
|
||||
|
||||
// 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()
|
||||
c.App.Admin.mu.Lock()
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Ctx) SetCoupleID(cid int64) { c.coupleID = cid; c.coupleResolved = true }
|
||||
func (c *Ctx) SetCoupleID(cid string) { c.coupleID = cid; c.coupleResolved = true }
|
||||
|
||||
@@ -16,20 +16,25 @@ import (
|
||||
"past/internal/config"
|
||||
"past/internal/geo"
|
||||
"past/internal/store"
|
||||
"past/internal/upload"
|
||||
"past/internal/wechat"
|
||||
"past/internal/wxpay"
|
||||
)
|
||||
|
||||
// App 全局依赖
|
||||
type App struct {
|
||||
WechatPay wxpay.Gateway
|
||||
Cfg *config.Config
|
||||
DB *store.DB
|
||||
Wechat *wechat.Client
|
||||
Geo *geo.Client
|
||||
Files *upload.Uploader
|
||||
Admin *AdminSessions
|
||||
}
|
||||
|
||||
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
|
||||
type Ctx struct {
|
||||
UploadResult *upload.Result
|
||||
G *gin.Context
|
||||
W http.ResponseWriter
|
||||
R *http.Request
|
||||
@@ -43,9 +48,9 @@ type Ctx struct {
|
||||
inputParsed bool
|
||||
body []byte
|
||||
bodyRead bool
|
||||
uid int64
|
||||
uid string
|
||||
uidResolved bool
|
||||
coupleID int64
|
||||
coupleID string
|
||||
coupleResolved bool
|
||||
responded bool
|
||||
}
|
||||
@@ -65,10 +70,20 @@ func (c *Ctx) JSON(code int, v any) error {
|
||||
return errResponded
|
||||
}
|
||||
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-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
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
@@ -108,6 +123,19 @@ func (c *Ctx) ErrCode(code int, msg string, errCode int) error {
|
||||
return c.JSON(code, M{"error": msg, "code": errCode})
|
||||
}
|
||||
|
||||
// Raw 输出原始响应(非 JSON),用于微信回调验签等需要纯文本响应的场景。
|
||||
func (c *Ctx) Raw(code int, contentType string, data []byte) error {
|
||||
if c.responded {
|
||||
return errResponded
|
||||
}
|
||||
c.responded = true
|
||||
if contentType == "" {
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
c.G.Data(code, contentType, data)
|
||||
return errResponded
|
||||
}
|
||||
|
||||
// M 便捷的 JSON 对象类型
|
||||
type M = map[string]any
|
||||
|
||||
@@ -159,6 +187,7 @@ func (c *Ctx) Input() map[string]any {
|
||||
m = renameKeys(m, snakeName).(map[string]any)
|
||||
}
|
||||
c.input = normalizeNumbers(m).(map[string]any)
|
||||
applyFileInputAliases(c.input, c.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,16 @@ func normalizeMediaInput(value any) any {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) {
|
||||
}
|
||||
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
|
||||
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"}
|
||||
normalized := normalizeMediaInput(input).(map[string]any)
|
||||
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package httpapi
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go.uber.org/zap"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"past/internal/model"
|
||||
"past/internal/store"
|
||||
"past/internal/upload"
|
||||
)
|
||||
|
||||
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
|
||||
@@ -41,6 +44,8 @@ func (r *Router) Handle(action string, h HandlerFunc) {
|
||||
r.handlers[action] = h
|
||||
}
|
||||
|
||||
func (r *Router) SetFileStorage(files *upload.Uploader) { r.app.Files = files }
|
||||
|
||||
// Actions 已注册的 action 列表(用于自检)
|
||||
func (r *Router) Actions() []string {
|
||||
out := make([]string, 0, len(r.handlers))
|
||||
@@ -52,9 +57,9 @@ func (r *Router) Actions() []string {
|
||||
|
||||
// 与 index.php 中 $publicActions / $adminActions 完全一致
|
||||
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
|
||||
"check_invite_code", "register_by_invite", "check_auth")
|
||||
"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")
|
||||
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 {
|
||||
m := make(map[string]bool, len(items))
|
||||
@@ -74,11 +79,68 @@ func (r *Router) Engine(debugMode bool) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
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.MaxMultipartMemory = 32 << 20
|
||||
|
||||
// 简洁 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/:action", r.serveAPI)
|
||||
@@ -148,7 +210,7 @@ func (r *Router) serveAPI(g *gin.Context) {
|
||||
|
||||
defer func() {
|
||||
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 {
|
||||
c.Err(500, fmt.Sprint(rec))
|
||||
}
|
||||
@@ -179,13 +241,20 @@ func (r *Router) serveAPI(g *gin.Context) {
|
||||
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]
|
||||
if !ok {
|
||||
c.Err(400, "未知操作")
|
||||
return
|
||||
}
|
||||
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())
|
||||
}
|
||||
if !c.responded {
|
||||
@@ -194,7 +263,7 @@ func (r *Router) serveAPI(g *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// accessLog gin 中间件:结构化访问日志(slog)
|
||||
// accessLog gin 中间件:结构化访问日志(zap)
|
||||
func accessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
@@ -206,7 +275,7 @@ func accessLog() gin.HandlerFunc {
|
||||
if uid, ok := c.Get("uid"); ok {
|
||||
attrs = append(attrs, "uid", uid)
|
||||
}
|
||||
slog.Info("http", attrs...)
|
||||
zap.S().Infow("http", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestCleanAPIRoutes(t *testing.T) {
|
||||
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("admin_login", func(c *Ctx) error {
|
||||
c.AdminLogin(1, "test")
|
||||
c.AdminLogin("1", "test")
|
||||
return c.JSON(200, M{"success": true})
|
||||
})
|
||||
router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -54,7 +54,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("cron_notify panic", "err", r)
|
||||
zap.S().Errorw("cron_notify panic", "err", r)
|
||||
}
|
||||
}()
|
||||
Once(db, wx, false)
|
||||
@@ -64,7 +64,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
|
||||
}
|
||||
|
||||
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 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。
|
||||
@@ -74,11 +74,11 @@ func Once(db *store.DB, wx Sender, force bool) {
|
||||
for _, action := range actionTypes {
|
||||
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 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 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)
|
||||
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')
|
||||
@@ -91,21 +91,21 @@ func Once(db *store.DB, wx Sender, force bool) {
|
||||
args = append(args, maxBatch)
|
||||
activities, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
slog.Error("cron_notify 查询失败", "err", err)
|
||||
zap.S().Errorw("cron_notify 查询失败", "err", err)
|
||||
return
|
||||
}
|
||||
processed, success, fail, skip := 0, 0, 0, 0
|
||||
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")
|
||||
if nickname == "" {
|
||||
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'
|
||||
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) {
|
||||
skip++
|
||||
continue
|
||||
@@ -136,9 +136,13 @@ func Once(db *store.DB, wx Sender, force bool) {
|
||||
if thing2 == "" {
|
||||
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++
|
||||
if ok {
|
||||
success++
|
||||
@@ -146,5 +150,5 @@ func Once(db *store.DB, wx Sender, force bool) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
|
||||
type testSender struct {
|
||||
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.to, s.from = to, from
|
||||
s.typ, s.data = typ, data
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) {
|
||||
{"force still checks sharing", 0, 0, true, true, 0},
|
||||
} {
|
||||
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"} {
|
||||
if !strings.Contains(query, part) {
|
||||
t.Errorf("missing guard %s", part)
|
||||
@@ -56,13 +59,31 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) {
|
||||
}
|
||||
db, _ := testdb.Open(t, steps)
|
||||
sender := &testSender{}
|
||||
Once(&store.DB{DB: db}, sender, tt.force)
|
||||
Once(store.WrapSQL(db), sender, tt.force)
|
||||
if sender.calls != tt.want {
|
||||
t.Fatalf("sent %d want %d", sender.calls, tt.want)
|
||||
}
|
||||
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)
|
||||
if sender.calls > 0 && (sender.to != "2" || sender.from != "1") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,113 +1,161 @@
|
||||
// Package store 封装 MySQL 访问。查询结果统一返回 Row(map),并按列类型转换成与 PHP PDO(mysqlnd)
|
||||
// 一致的 JSON 表现:整数列 → 数字,DECIMAL/聚合 SUM/AVG → 字符串,日期时间 → 字符串,NULL → null。
|
||||
// Package store provides GORM repositories and scoped PostgreSQL report queries.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 {
|
||||
*sql.DB
|
||||
// NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为:
|
||||
// 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。
|
||||
ORM *gorm.DB
|
||||
NumericAsString bool
|
||||
UploadDir string // 用于解绑完成后的持久化附件清理队列
|
||||
}
|
||||
|
||||
// Row 一行查询结果,键为列名(或别名)。
|
||||
type Row map[string]any
|
||||
|
||||
// Open connects to an existing PostgreSQL database; it never creates or migrates it.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
pool.SetMaxOpenConns(20)
|
||||
pool.SetMaxIdleConns(5)
|
||||
pool.SetConnMaxLifetime(time.Hour)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
if err := pool.PingContext(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &DB{DB: db}, nil
|
||||
return &DB{ORM: orm}, nil
|
||||
}
|
||||
|
||||
// Querier 由 *DB 与 *Tx 共同实现,便于处理函数在事务内外复用。
|
||||
type Querier interface {
|
||||
Query(query string, args ...any) ([]Row, error)
|
||||
QueryOne(query string, args ...any) (Row, error)
|
||||
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...)
|
||||
// EnsureDatabase creates only the configured database, using the maintenance database.
|
||||
// Database identifiers are quoted by pgx, never interpolated as SQL fragments.
|
||||
func EnsureDatabase(dsn string) error {
|
||||
cfg, err := pgx.ParseConfig(dsn)
|
||||
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 {
|
||||
*sql.Tx
|
||||
ORM *gorm.DB
|
||||
numStr bool
|
||||
}
|
||||
|
||||
func (d *DB) Begin() (*Tx, error) {
|
||||
tx, err := d.DB.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
t := d.ORM.Begin()
|
||||
return &Tx{ORM: t, numStr: d.NumericAsString}, t.Error
|
||||
}
|
||||
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) {
|
||||
return queryRows(t.Tx, t.numStr, query, 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...)
|
||||
func queryOne(db *gorm.DB, numStr bool, q string, args ...any) (Row, error) {
|
||||
rows, err := queryRows(db, numStr, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,8 +165,12 @@ func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error)
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, error) {
|
||||
rs, err := r.QueryContext(context.Background(), query, args...)
|
||||
func queryRows(db *gorm.DB, numStr bool, q string, args ...any) ([]Row, error) {
|
||||
scoped, err := scopeQuery(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs, err := db.Raw(scoped, args...).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -131,73 +183,56 @@ func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, erro
|
||||
if err != nil {
|
||||
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)
|
||||
raw := make([]sql.RawBytes, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range raw {
|
||||
ptrs[i] = &raw[i]
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
row := make(Row, len(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)
|
||||
}
|
||||
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
|
||||
|
||||
const (
|
||||
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)
|
||||
}
|
||||
}
|
||||
// ExecNoFK preserves the legacy callback API. The new models deliberately model
|
||||
// actor references as scalar UUIDs (system activities use a NULL user_id).
|
||||
func (d *DB) ExecNoFK(fn func(Querier) error) error { return fn(d) }
|
||||
|
||||
// ---- Row 取值辅助(对应 PHP 弱类型读取)----
|
||||
|
||||
@@ -337,39 +372,3 @@ func atoiPrefix(s string) int64 {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package store
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -16,13 +16,13 @@ func (db *DB) AdvanceUnbindings() error {
|
||||
return err
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
func (db *DB) finishUnbinding(cid int64) error {
|
||||
func (db *DB) finishUnbinding(cid string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -36,9 +36,6 @@ func (db *DB) finishUnbinding(cid int64) error {
|
||||
if err != nil || req == nil {
|
||||
return err
|
||||
}
|
||||
if err := queueUnboundFiles(tx, cid, cp.Str("couple_space_id")); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range sharedDataDeleteStatements {
|
||||
args := []any{cid}
|
||||
if strings.Contains(stmt, "bgver_") {
|
||||
@@ -62,11 +59,9 @@ func (db *DB) finishUnbinding(cid int64) error {
|
||||
func (db *DB) RunUnbindingWorker() {
|
||||
run := func() {
|
||||
if err := db.AdvanceUnbindings(); err != nil {
|
||||
slog.Error("解绑到期处理失败", "err", err)
|
||||
}
|
||||
if err := db.cleanUnboundFiles(); err != nil {
|
||||
slog.Error("解绑附件清理失败,将重试", "err", err)
|
||||
zap.S().Errorw("解绑到期处理失败", "err", err)
|
||||
}
|
||||
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
@@ -83,22 +78,22 @@ func (db *DB) MigrateSmokingRecords() error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if marker.Str("value") == "done" {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 tx.Commit()
|
||||
@@ -112,27 +107,27 @@ func (db *DB) MigrateSmokingRecords() error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
latestKeys := map[string]int64{}
|
||||
latestKeys := map[string]string{}
|
||||
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 {
|
||||
uid := row.Int("user_id")
|
||||
uid := row.Str("user_id")
|
||||
balances[uid] += row.Int("delta")
|
||||
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]
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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 tx.Commit()
|
||||
|
||||
@@ -22,7 +22,6 @@ func TestCoolingCompletionRechecksUnderLock(t *testing.T) {
|
||||
{Match: "cooling_until <= NOW() FOR UPDATE", Columns: []string{"id"}, Rows: reqRows},
|
||||
}
|
||||
if !cancelled {
|
||||
steps = append(steps, cleanupQuerySteps()...)
|
||||
for _, stmt := range sharedDataDeleteStatements {
|
||||
args := []any{int64(7)}
|
||||
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'"})
|
||||
}
|
||||
sqlDB, s := testdb.Open(t, steps)
|
||||
db := &DB{DB: sqlDB}
|
||||
db := WrapSQL(sqlDB)
|
||||
if err := db.AdvanceUnbindings(); err != nil {
|
||||
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(8)}}},
|
||||
}
|
||||
steps = append(steps, cleanupQuerySteps()...)
|
||||
steps = append(steps, testdb.Step{Match: sharedDataDeleteStatements[0], Err: errors.New("database unavailable")})
|
||||
sqlDB, s := testdb.Open(t, steps)
|
||||
db := &DB{DB: sqlDB}
|
||||
db := WrapSQL(sqlDB)
|
||||
if err := db.AdvanceUnbindings(); err == nil {
|
||||
t.Fatal("failure ignored")
|
||||
}
|
||||
@@ -64,8 +62,8 @@ func TestCoolingFailureRollsBackRelationship(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"}}}})
|
||||
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil {
|
||||
sqlDB, s := testdb.Open(t, []testdb.Step{{Match: "INSERT INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}})
|
||||
if err := (WrapSQL(sqlDB)).MigrateSmokingRecords(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Commits != 1 {
|
||||
@@ -75,8 +73,10 @@ func TestSmokingMigrationDoesNotRepeat(t *testing.T) {
|
||||
|
||||
func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
|
||||
checkEntry := func(id string, balance int64, key string) func(string, []any) error {
|
||||
return func(_ string, a []any) error {
|
||||
if a[0] != id || a[1] != int64(1) || a[5] != balance {
|
||||
return func(q string, a []any) error {
|
||||
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)
|
||||
}
|
||||
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"}
|
||||
sqlDB, s := testdb.Open(t, []testdb.Step{
|
||||
{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
|
||||
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}},
|
||||
{Match: "INSERT INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
|
||||
{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: "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{
|
||||
{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(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: "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)
|
||||
}
|
||||
if s.Commits != 1 {
|
||||
@@ -112,26 +112,15 @@ func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
|
||||
|
||||
func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) {
|
||||
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: "WHERE `key`='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}},
|
||||
{Match: "UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"},
|
||||
{Match: "WHERE key='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}},
|
||||
{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)
|
||||
}
|
||||
if script.Commits != 1 {
|
||||
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"}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+123
-154
@@ -1,185 +1,154 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"past/internal/model"
|
||||
)
|
||||
|
||||
type addColumn struct {
|
||||
Table, Column, DDL string
|
||||
}
|
||||
|
||||
// InitSchema 对应 install.php 的 initDatabase():建表、补列、默认数据、历史数据修正。
|
||||
// PHP 版本每次请求都执行;Go 版本只在启动时执行一次。对已迁移的线上库幂等。
|
||||
// InitSchema is transactional and serialized across concurrently starting servers.
|
||||
// Models, rather than handwritten DDL, are the source of truth for all tables.
|
||||
func (db *DB) InitSchema() error {
|
||||
// 会话级设置(SET NAMES / FOREIGN_KEY_CHECKS)必须落在同一条连接上,因此整个初始化独占一条连接
|
||||
conn, err := db.DB.Conn(context.Background())
|
||||
if err != nil {
|
||||
return db.ORM.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(520001)").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
d := &connRunner{conn}
|
||||
if _, err := d.Exec("SET NAMES utf8mb4"); err != nil {
|
||||
// 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
|
||||
}
|
||||
// 与 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
|
||||
}
|
||||
}
|
||||
|
||||
// 补列(含 users.phone 的特殊迁移逻辑)
|
||||
for _, ac := range addColumns {
|
||||
exists, err := d.columnExists(ac.Table, ac.Column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
current := st.Schema.Table
|
||||
previous := "past-" + strings.TrimPrefix(current, "past_")
|
||||
if !tx.Migrator().HasTable(previous) {
|
||||
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 tx.Migrator().HasTable(current) {
|
||||
return fmt.Errorf("同时存在 %s 与 %s,停止迁移以避免覆盖数据", previous, current)
|
||||
}
|
||||
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 的设计)。
|
||||
// 旧库建表时为 NOT NULL,这里做幂等的放宽约束迁移。
|
||||
for _, t := range []string{"period_records", "period_daily_records", "period_sex_records"} {
|
||||
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 {
|
||||
if err := tx.Migrator().RenameTable(previous, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if np.Int("cnt") > 0 {
|
||||
first, _ := d.QueryOne("SELECT id FROM users LIMIT 1")
|
||||
if first != nil {
|
||||
d.Exec("UPDATE photos SET created_by = ? WHERE created_by IS NULL", first.Int("id"))
|
||||
}
|
||||
}
|
||||
|
||||
// 修正已有错误设置值
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 补全历史数据: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
|
||||
}
|
||||
idx, err := d.Query("SHOW INDEX FROM unbind_requests WHERE Key_name='unbind_deadline'")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(idx) == 0 {
|
||||
if _, err := d.Exec("CREATE INDEX unbind_deadline ON unbind_requests(status,cooling_until)"); 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 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",
|
||||
`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 := d.Exec(stmt); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if err := initStoragePlans(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&admin).Updates(map[string]any{"username": "past", "password": string(hash)}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func initStoragePlans(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Unscoped().Model(&model.StoragePlan{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
for i, key := range []string{"month", "year"} {
|
||||
months, price, name := int64(1), int64(500), "包月无限空间"
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
ent := model.StorageEntitlement{UserId: user.Id, SourceKey: "legacy:" + user.Id, PlanKey: user.StoragePlan, PlanName: "原无限空间会员", StartsAt: time.Now(), ExpiresAt: *user.StorageVipExpireAt}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,490 +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`,
|
||||
}
|
||||
|
||||
// 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=数据已删除'"},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 仅删除当前情侣空间的数据,不按用户 ID 删除其他空间或个人账本。
|
||||
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 diary_comments 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 diaries 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 notify_log WHERE couple_id = ?",
|
||||
"DELETE FROM subscribe_message_logs WHERE couple_id = ?",
|
||||
"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
|
||||
"DELETE FROM settings WHERE key IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))",
|
||||
}
|
||||
|
||||
@@ -1,61 +1,17 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"os"
|
||||
"past/internal/testdb"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanupPathsProtectPersonalAndOutsideFiles(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"} {
|
||||
if cleanupPath(input) != "" {
|
||||
t.Fatalf("unsafe or personal path accepted: %q", input)
|
||||
func TestSharedDeletionRemainsScopedToTheCurrentSpace(t *testing.T) {
|
||||
for _, q := range sharedDataDeleteStatements {
|
||||
if !strings.Contains(q, "WHERE") || !strings.Contains(q, "?") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"gorm.io/gorm/schema"
|
||||
"io"
|
||||
"past/internal/model"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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]
|
||||
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)
|
||||
}
|
||||
if s.Args != nil {
|
||||
@@ -86,7 +92,8 @@ func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
|
||||
for i, a := range args {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +106,7 @@ func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
_ = rawQuery
|
||||
return s, s.Err
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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++
|
||||
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")
|
||||
}
|
||||
@@ -1,19 +1,33 @@
|
||||
// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。
|
||||
// Package upload validates uploads, deduplicates content and stores objects in MinIO.
|
||||
package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"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_* 常量)
|
||||
@@ -28,26 +42,75 @@ const (
|
||||
ErrMethodNotAllowed = 1008
|
||||
ErrPartnerInvalid = 1009
|
||||
ErrFileTooLarge = 1010
|
||||
ErrStorageQuotaExceeded = 10001
|
||||
)
|
||||
|
||||
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
|
||||
|
||||
// ImageChecker 图片内容安全检测(由 wechat 包实现)
|
||||
type ImageChecker interface {
|
||||
CheckImage(filePath, openid string) (bool, string)
|
||||
CheckImage(string, string) (bool, string)
|
||||
}
|
||||
|
||||
type Uploader struct {
|
||||
// Dir 上传根目录(UPLOAD_DIR)
|
||||
Dir string
|
||||
HTTPClient *http.Client
|
||||
MaxSize int64
|
||||
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' => ...]
|
||||
type Result struct {
|
||||
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg
|
||||
Error string
|
||||
func New(cfg config.MinioConfig, db *store.DB, maxSize int64, checker ImageChecker) (*Uploader, error) {
|
||||
endpoint := cfg.Endpoint
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
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}
|
||||
@@ -62,8 +125,8 @@ func CouplePath(spaceID, typ string) string {
|
||||
}
|
||||
|
||||
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
|
||||
func UserFilePath(userID int64, typ string) string {
|
||||
p := fmt.Sprintf("users/%d", userID)
|
||||
func UserFilePath(userID string, typ string) string {
|
||||
p := fmt.Sprintf("users/%s", userID)
|
||||
if typ != "" {
|
||||
p += "/" + typ
|
||||
}
|
||||
@@ -82,113 +145,217 @@ func Ext(name string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
}
|
||||
|
||||
// Handle 对应 handleUpload($file, $subDir, $prefixPath)
|
||||
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result {
|
||||
// The directory arguments remain source-compatible; object keys always use date folders.
|
||||
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 {
|
||||
return Result{Error: "未接收到文件"}
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
return Result{Error: "未接收到文件"}
|
||||
return Result{Error: "文件读取失败"}
|
||||
}
|
||||
defer f.Close()
|
||||
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath)
|
||||
return u.save(f, fh.Filename, userId)
|
||||
}
|
||||
|
||||
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源
|
||||
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result {
|
||||
if size > u.MaxSize {
|
||||
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)}
|
||||
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefix string) Result {
|
||||
return u.save(f, filename, "")
|
||||
}
|
||||
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefix string) Result {
|
||||
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)
|
||||
if !allowedExts[ext] {
|
||||
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"}
|
||||
return Result{Error: "不支持的文件类型"}
|
||||
}
|
||||
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝
|
||||
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)
|
||||
tmp, err := os.CreateTemp("", "past-upload-*."+ext)
|
||||
if err != nil {
|
||||
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
|
||||
return Result{Error: "创建临时文件失败"}
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
md5Hash, shaHash := md5.New(), sha256.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, md5Hash, shaHash), io.LimitReader(src, u.MaxSize+1))
|
||||
if err != nil {
|
||||
return Result{Error: "读取文件失败"}
|
||||
}
|
||||
if size == 0 || size > u.MaxSize {
|
||||
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 _, err := io.Copy(out, src); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dest)
|
||||
return Result{Error: "文件保存失败。系统错误: " + err.Error()}
|
||||
}
|
||||
out.Close()
|
||||
slog.Info("upload saved", "path", rel+name)
|
||||
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", u.Bucket+":"+digest).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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(dest, ""); !pass {
|
||||
os.Remove(dest)
|
||||
return Result{Error: msg}
|
||||
if pass, msg := u.Checker.CheckImage(tmp.Name(), ""); !pass {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
return Result{Path: rel + name}
|
||||
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
|
||||
}
|
||||
|
||||
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。
|
||||
func (u *Uploader) Remove(relPath string) {
|
||||
if relPath == "" || strings.Contains(relPath, "..") {
|
||||
return
|
||||
}
|
||||
p := strings.TrimPrefix(relPath, "uploads/")
|
||||
if p == relPath {
|
||||
return
|
||||
}
|
||||
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
|
||||
}
|
||||
// Removing one business reference must never delete a shared physical object.
|
||||
func (u *Uploader) Remove(path string) {}
|
||||
|
||||
// Abs 返回相对路径对应的绝对路径
|
||||
func (u *Uploader) Abs(relPath string) string {
|
||||
p := strings.TrimPrefix(relPath, "uploads/")
|
||||
return filepath.Join(u.Dir, filepath.FromSlash(p))
|
||||
// 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 nil
|
||||
}
|
||||
|
||||
// Exists 文件是否存在
|
||||
func (u *Uploader) Exists(relPath string) bool {
|
||||
st, err := os.Stat(u.Abs(relPath))
|
||||
return err == nil && !st.IsDir()
|
||||
func (u *Uploader) RunFileCleanupWorker() {
|
||||
for {
|
||||
if err := u.CleanupDeletedFiles(); err != nil {
|
||||
zap.S().Errorw("注销文件清理失败,将自动重试", "err", err)
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"past/internal/store"
|
||||
"past/internal/testdb"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type subscribeTransport func(*http.Request) (*http.Response, error)
|
||||
@@ -41,19 +41,18 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
||||
if tt.subscribed {
|
||||
auth = [][]driver.Value{{int64(1)}}
|
||||
}
|
||||
steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth})
|
||||
}
|
||||
if tt.code == int64(43101) {
|
||||
steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"})
|
||||
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"}}}}
|
||||
steps = append(steps, meta...)
|
||||
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)
|
||||
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])
|
||||
}
|
||||
status := "failed"
|
||||
@@ -81,23 +80,26 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
||||
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"})
|
||||
db, _ := testdb.Open(t, steps)
|
||||
client := New("app", "secret", tt.template, t.TempDir(), &store.DB{DB: db})
|
||||
os.MkdirAll(filepath.Dir(client.cacheFile()), 0755)
|
||||
os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600)
|
||||
client := New("app", "secret", tt.template, testRedis(t), "test:", store.WrapSQL(db))
|
||||
client.Redis.Set(context.Background(), client.tokenKey(), "cached-token", time.Hour)
|
||||
calls := 0
|
||||
client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
var payload map[string]any
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("success=%v requests=%d", success, calls)
|
||||
}
|
||||
@@ -110,25 +112,26 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
|
||||
{Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"recipient"}}},
|
||||
{Match: "SELECT id FROM subscribe_templates", Columns: []string{"id"}},
|
||||
{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 {
|
||||
if args[0] != int64(0) || args[4] != "recipient" || args[11] != "failed" {
|
||||
{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error {
|
||||
v := testdb.InsertArgs(q, args)
|
||||
if v["activity_id"] != nil || v["to_openid"] != "recipient" || v["status"] != "failed" {
|
||||
t.Error("missing zero-activity log")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db})
|
||||
if client.SendSubscribeMessage(2, "notify", nil, "", 0, 0) {
|
||||
client := New("app", "secret", "template", testRedis(t), "test:", store.WrapSQL(db))
|
||||
if client.SendSubscribeMessage("2", "notify", nil, "", "", "") {
|
||||
t.Fatal("unsubscribed send succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulEventIsNotSentAgain(t *testing.T) {
|
||||
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 })
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -23,7 +25,8 @@ type Client struct {
|
||||
AppID string
|
||||
Secret string
|
||||
NotifyTemplateID string
|
||||
DataDir string // 用于 access_token.cache
|
||||
Redis redis.UniversalClient
|
||||
KeyPrefix string
|
||||
DB *store.DB
|
||||
|
||||
http *http.Client
|
||||
@@ -31,51 +34,16 @@ type Client struct {
|
||||
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{
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
// Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES
|
||||
func (c *Client) Templates() map[string]string {
|
||||
return map[string]string{"notify": 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()
|
||||
return map[string]string{"notify": c.NotifyTemplateID, "checkin": c.NotifyTemplateID}
|
||||
}
|
||||
|
||||
// postJSON 对应 wechatPost:非 200 返回 nil
|
||||
@@ -160,49 +128,53 @@ func (c *Client) CheckImage(filePath, openid string) (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// Jscode2Session 通过 code 换取 openid。返回 (openid, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。
|
||||
func (c *Client) Jscode2Session(code string) (openid string, wxErr string, available bool) {
|
||||
// Jscode2Session 通过 code 换取 openid 与 session_key。
|
||||
// 返回 (openid, sessionKey, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。
|
||||
func (c *Client) Jscode2Session(code string) (openid string, sessionKey string, wxErr string, available bool) {
|
||||
u := "https://api.weixin.qq.com/sns/jscode2session?appid=" + c.AppID + "&secret=" + c.Secret + "&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code"
|
||||
resp, err := c.http.Get(u)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if len(b) == 0 {
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return "", "", true
|
||||
return "", "", "", true
|
||||
}
|
||||
if ec, ok := data["errcode"]; ok && store.ToInt(ec) != 0 {
|
||||
msg := store.ToStr(data["errmsg"])
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return "", msg, true
|
||||
return "", "", msg, true
|
||||
}
|
||||
return store.ToStr(data["openid"]), "", true
|
||||
return store.ToStr(data["openid"]), store.ToStr(data["session_key"]), "", true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lock := &c.sendLocks[uint64(activityID+toUserID)%uint64(len(c.sendLocks))]
|
||||
if activityID != "" {
|
||||
lock := &c.sendLocks[messageLockHash(activityID+toUserID)%uint64(len(c.sendLocks))]
|
||||
lock.Lock()
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error("读取通知去重状态失败", "err", err)
|
||||
zap.S().Errorw("读取通知去重状态失败", "err", err)
|
||||
return false
|
||||
}
|
||||
if previous != nil && previous.Str("status") == "success" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
data = normalizeSubscribeData(data)
|
||||
data = ReminderMessage(data)
|
||||
started := time.Now()
|
||||
templateID, known := c.Templates()[templateType]
|
||||
status, lastError, openid := "failed", "", ""
|
||||
@@ -213,7 +185,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
}
|
||||
defer func() {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@@ -233,7 +205,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
lastError = "用户无openid"
|
||||
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 {
|
||||
lastError = "读取订阅授权失败"
|
||||
return false
|
||||
@@ -259,7 +231,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
if tok == "" {
|
||||
lastError = "获取access_token失败"
|
||||
if attempt < 2 {
|
||||
c.clearTokenCache()
|
||||
c.clearTokenCache(tok)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -273,7 +245,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
lastError += ": " + uerr.Err.Error()
|
||||
}
|
||||
if attempt < 2 {
|
||||
c.clearTokenCache()
|
||||
c.clearTokenCache(tok)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
continue
|
||||
@@ -283,7 +255,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode)
|
||||
if attempt < 2 {
|
||||
c.clearTokenCache()
|
||||
c.clearTokenCache(tok)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
continue
|
||||
@@ -304,12 +276,11 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
||||
}
|
||||
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
|
||||
if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 {
|
||||
c.clearTokenCache()
|
||||
c.clearTokenCache(tok)
|
||||
continue
|
||||
}
|
||||
if ec == 40003 || ec == 43101 {
|
||||
c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType)
|
||||
}
|
||||
// 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录:
|
||||
// 登记不代表剩余次数;用户后续点击时仍需调用订阅授权补充次数。
|
||||
break
|
||||
}
|
||||
return false
|
||||
@@ -335,9 +306,9 @@ func normalizeSubscribeData(data map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
// 每次逻辑发送追加一条记录;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
|
||||
if lookup == 0 {
|
||||
if lookup == "" {
|
||||
lookup = toUserID
|
||||
}
|
||||
var coupleID any
|
||||
@@ -345,7 +316,7 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templat
|
||||
coupleID = user["couple_id"]
|
||||
}
|
||||
action := ""
|
||||
if activityID != 0 {
|
||||
if activityID != "" {
|
||||
if activity, _ := c.DB.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID); activity != nil {
|
||||
action = activity.Str("action_type")
|
||||
}
|
||||
@@ -357,19 +328,23 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templat
|
||||
if status == "success" {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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 {
|
||||
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()
|
||||
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
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
lookup := userID
|
||||
if lookup == 0 {
|
||||
if lookup == "" {
|
||||
lookup = toUserID
|
||||
}
|
||||
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup)
|
||||
@@ -378,7 +353,7 @@ func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateTyp
|
||||
coupleID = u["couple_id"]
|
||||
}
|
||||
actionType := ""
|
||||
if activityID != 0 {
|
||||
if activityID != "" {
|
||||
a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID)
|
||||
if a != nil {
|
||||
actionType = a.Str("action_type")
|
||||
@@ -393,7 +368,7 @@ func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateTyp
|
||||
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("logNotifyResult error", "err", err)
|
||||
zap.S().Errorw("logNotifyResult error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,3 +381,9 @@ func marshalUnescaped(v any) ([]byte, error) {
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
func messageLockHash(value string) uint64 {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(value))
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import UnbindPage from "@/pages/Unbind"
|
||||
import PetsPage from "@/pages/Pets"
|
||||
import SubscribeMessagesPage from "@/pages/SubscribeMessages"
|
||||
import SettingsPage from "@/pages/Settings"
|
||||
import StoragePage from "@/pages/Storage"
|
||||
import FilesPage from "@/pages/Files"
|
||||
|
||||
// 登录态:admin_check_auth(Cookie 会话)
|
||||
export function useAuth() {
|
||||
@@ -45,6 +47,8 @@ export default function App() {
|
||||
<Route path="unbind" element={<UnbindPage />} />
|
||||
<Route path="pets" element={<PetsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="storage" element={<StoragePage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="subscribe-messages" element={<SubscribeMessagesPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 q = useQuery({
|
||||
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 d = q.data
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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") })
|
||||
if (q.isPending) return <Loading />
|
||||
if (q.isError) return <ErrorBox error={q.error} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user