feat: migrate to PostgreSQL GORM and MinIO file storage
Use UUID models and soft deletion, persist file relationships with deduplicated monthly MinIO objects, and add admin file management. Update mini-program media handling and add local dev configuration.
This commit is contained in:
@@ -7,6 +7,7 @@ run/
|
||||
|
||||
# 本地配置(含密钥,不入库)
|
||||
config.yaml
|
||||
config.dev.yaml
|
||||
# 生产部署配置模板(含数据库密码,不入库;部署时复制到服务器后按需修改)
|
||||
deploy/linux/config.prod.yaml
|
||||
|
||||
|
||||
+5
-1
@@ -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 diff clean
|
||||
|
||||
all: web build
|
||||
|
||||
@@ -33,6 +33,10 @@ 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 ./...
|
||||
|
||||
|
||||
+53
-3
@@ -1,6 +1,56 @@
|
||||
# 本地 dev 调试
|
||||
|
||||
在 `past-go/` 目录执行 `make dev`,使用 `config.dev.yaml` 启动,监听 `:8080`。
|
||||
本地配置包含 AppSecret,已加入 Git 忽略;新环境可复制 `config.dev.example.yaml` 后填写。
|
||||
管理端地址:`http://localhost:8080/backend/admin/`。
|
||||
|
||||
小程序 `weapp/config/env.js` 根据微信运行版本选择地址:开发版为 `http://127.0.0.1:8080/api`,体验版/正式版为线上地址。
|
||||
开发工具需关闭合法域名校验(项目已设置);真机调试将 dev 地址改为电脑的局域网 IP。
|
||||
小程序 AppID 为 `wxb463820bf36dd5d6`,AppSecret 仅存放在后端本地配置。
|
||||
dev 继续使用当前 `past-go` 数据库与 MinIO bucket,并非独立测试数据。
|
||||
日志和缓存写入 `run/dev/data/`,定时补发通知关闭。
|
||||
新小程序的订阅模板和虚拟支付参数需在对应平台获取后填写,dev 配置暂时留空。
|
||||
|
||||
# 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(测试数据全部回滚)。
|
||||
|
||||
MinIO 配置读取 `minio` 节点。`past_files` 保存 UUID、size、suffix、content_type、object_key、md5、sha256、url、bucket 等元数据;对象键格式为 `YYYY/MM/<md5>.<suffix>`。同内容并发上传通过 PostgreSQL 锁和唯一索引去重,不重复上传对象。
|
||||
|
||||
头像、相册封面、照片、打卡图片和背景使用带外键的文件 ID 字段;日记/记忆日图片使用 `past_record_file` 按 position 保存多图顺序。旧接口的路径字段在数据库中仅保存 ID 镜像,响应时解析为稳定文件访问地址;新请求可直接提交 `fileId`、`avatarFileId`、`coverFileId`、`imageFileIds` 等字段。`past_file_upload` 独立记录每个上传用户,既保留共享去重,也保证权限与容量统计正确。
|
||||
|
||||
上传成功返回 `fileId`、`path`、`url`、`file`、`duplicate`。`/api/files/:fileId` 生成短期 MinIO 签名链接,维持原图片的可访问方式,不修改 bucket 的公共访问策略。删除业务关联只做逻辑删除,不删除共享对象。容量检查在内容去重后于事务内执行,同文件不会重复占用同一空间的额度。
|
||||
|
||||
后台 **文件管理**(`/backend/admin/files`)通过管理员接口 `/api/adminFiles` 查看全部文件、上传用户、预览、对象路径及元数据,支持关键词、类型、日期筛选和分页。普通用户不能调用文件管理接口。
|
||||
|
||||
真实 PostgreSQL/MinIO 回归:设置 `PAST_TEST_POSTGRES_DSN` 后运行 `go test ./...`。测试读取本地 `config.yaml` 中的 MinIO 连接,可通过 `PAST_TEST_CONFIG` 指定其他配置文件的绝对路径,使用隔离的 schema/bucket,测试结束清理;微信/地图外部请求使用模拟依赖。
|
||||
|
||||
以下保留历史说明,数据库配置与部署步骤以以上内容为准。
|
||||
|
||||
---
|
||||
|
||||
# 我们past后端(Go 版)
|
||||
|
||||
`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。
|
||||
`backend/api/index.php` 的 Go 重写:Gin + GORM + PostgreSQL,管理后台(React)通过 `embed` 打进同一个二进制。
|
||||
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
|
||||
|
||||
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。
|
||||
@@ -10,10 +60,10 @@
|
||||
```
|
||||
cmd/server/ 入口
|
||||
internal/config/ config.yaml 读取
|
||||
internal/store/ MySQL 访问(行类型对齐 PDO)、schema.go = install.php 的移植
|
||||
internal/store/ GORM 数据访问、PostgreSQL 自动迁移与逻辑删除
|
||||
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/upload/ MinIO 上传、MD5 去重、日期目录与签名访问
|
||||
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session
|
||||
internal/geo/ 腾讯位置服务
|
||||
internal/notify/ cron_notify.php 的进程内定时版
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
os.Exit(1)
|
||||
}
|
||||
// 确保必要目录存在(对应 config.php 中的 $dirs)
|
||||
for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} {
|
||||
for _, d := range []string{"data", "data/tmp"} {
|
||||
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755)
|
||||
}
|
||||
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径)
|
||||
@@ -93,12 +93,15 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
if err := store.EnsureDatabase(cfg.DSN()); err != nil {
|
||||
slog.Error("创建 PostgreSQL 数据库失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := store.Open(cfg.DSN())
|
||||
if err != nil {
|
||||
slog.Error("数据库连接失败", "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)
|
||||
@@ -122,7 +125,11 @@ 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}
|
||||
uploader, err := upload.New(cfg.Minio, db, cfg.MaxUploadMB*1024*1024, wx)
|
||||
if err != nil {
|
||||
slog.Error("MinIO 初始化失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
router := httpapi.NewRouter(app, web.AdminFS())
|
||||
handler.Register(router, uploader)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 本地 dev 配置(模板,复制为 config.dev.yaml 后补充密钥)
|
||||
listen: ":8080"
|
||||
# 本地日志与缓存独立目录;文件存储使用下方 MinIO
|
||||
data_dir: "./run/dev"
|
||||
debug: true
|
||||
php_compat: "8.0"
|
||||
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: "" # 待填写新 AppID 下的订阅消息模板 ID
|
||||
tencent_map_key: ""
|
||||
max_upload_mb: 50
|
||||
# 定时补发通知间隔(分钟),0 关闭
|
||||
notify_interval_min: 0 # 本地调试关闭定时补发
|
||||
# 微信小程序虚拟支付(米大师虚拟支付2.0)
|
||||
virtual_pay:
|
||||
appid: wxb463820bf36dd5d6
|
||||
offer_id: "" # 待填写新 AppID 对应的支付应用 ID
|
||||
app_key: ""
|
||||
sandbox_key: "" # 待填写新应用的沙箱 AppKey
|
||||
env: 1
|
||||
notify_token: ""
|
||||
notify_aes_key: ""
|
||||
|
||||
minio:
|
||||
endpoint: https://s3.sundynix.cn
|
||||
access_key: your_minio_access_key
|
||||
secret_key: your_minio_secret_key
|
||||
bucket: past-go
|
||||
use_ssl: true
|
||||
@@ -3,11 +3,12 @@ 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
|
||||
@@ -25,3 +26,11 @@ virtual_pay:
|
||||
env: 1 # 0-正式环境 1-沙箱环境
|
||||
notify_token: "2029807d6e6441149c138eee3254c27c" # 支付结果回调 Token(微信后台消息推送配置)
|
||||
notify_aes_key: "" # 支付结果回调 EncodingAESKey(可选)
|
||||
|
||||
|
||||
minio:
|
||||
endpoint: https://s3.sundynix.cn
|
||||
access_key: your_minio_access_key
|
||||
secret_key: your_minio_secret_key
|
||||
bucket: past-go
|
||||
use_ssl: true
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
# PostgreSQL / GORM 数据层(当前版本)
|
||||
|
||||
数据库为 `192.168.100.127:5432/past-go`,账号 `root`;连接配置见 `config.example.yaml`。
|
||||
复制为本地 `config.yaml` 并填写微信配置。启动时先连接 `postgres` 管理库检查并创建目标库,再执行 GORM AutoMigrate。
|
||||
也可以只运行迁移:
|
||||
|
||||
```sh
|
||||
go run ./cmd/migrate -config config.yaml
|
||||
```
|
||||
|
||||
模型定义在 `internal/model/models.go`,所有模型嵌入 `BaseModel`:`Id string`、`CreatedAt`、`UpdatedAt`、`DeletedAt`。
|
||||
主键与关联 ID 使用 PostgreSQL `uuid` 类型;主键默认 `gen_random_uuid()`,不自增。
|
||||
表名统一为 `past_` + 单数 snake_case(例如 `User` → `past_user`、`DiaryComment` → `past_diary_comment`);字段按 `UserId` → `user_id` 命名。
|
||||
启动时会将旧 `past-` 前缀的表无损重命名为 `past_`,保留 UUID、数据和索引。
|
||||
|
||||
新增、修改、删除通过 GORM 模型执行;复杂报表通过 GORM Raw 执行,并统一过滤所有关联表及子查询中的 `deleted_at`。
|
||||
删除保留原行及附件;活跃记录的唯一索引带 `WHERE deleted_at IS NULL`,删除后可以重新创建同业务键的记录。
|
||||
用户对外的六位展示编号是独立业务字段,继续使用数据库序列分配;它不是 UUID 主键。情侣空间编号为 `CP` + 不带连字符的情侣 UUID。
|
||||
|
||||
本次创建的是新的 PostgreSQL 库,不会导入旧 MySQL 数据。历史备份如需迁入,需要单独完成数据转换和旧 ID 到 UUID 的关联映射。
|
||||
|
||||
验证:`go test ./...`、`go vet ./...`;在设置 `PAST_TEST_POSTGRES_DSN` 后,可运行 `go test ./internal/store -run TestPostgresIntegration -v` 验证真实 PostgreSQL(测试数据全部回滚)。
|
||||
|
||||
以下保留历史说明,数据库配置与部署步骤以以上内容为准。
|
||||
|
||||
---
|
||||
|
||||
# 20261008 原权限兼容与抽烟功能
|
||||
|
||||
## 当前行为
|
||||
|
||||
@@ -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,3 +1,30 @@
|
||||
# PostgreSQL / GORM 数据层(当前版本)
|
||||
|
||||
数据库为 `192.168.100.127:5432/past-go`,账号 `root`;连接配置见 `config.example.yaml`。
|
||||
复制为本地 `config.yaml` 并填写微信配置。启动时先连接 `postgres` 管理库检查并创建目标库,再执行 GORM AutoMigrate。
|
||||
也可以只运行迁移:
|
||||
|
||||
```sh
|
||||
go run ./cmd/migrate -config config.yaml
|
||||
```
|
||||
|
||||
模型定义在 `internal/model/models.go`,所有模型嵌入 `BaseModel`:`Id string`、`CreatedAt`、`UpdatedAt`、`DeletedAt`。
|
||||
主键与关联 ID 使用 PostgreSQL `uuid` 类型;主键默认 `gen_random_uuid()`,不自增。
|
||||
表名统一为 `past_` + 单数 snake_case(例如 `User` → `past_user`、`DiaryComment` → `past_diary_comment`);字段按 `UserId` → `user_id` 命名。
|
||||
启动时会将旧 `past-` 前缀的表无损重命名为 `past_`,保留 UUID、数据和索引。
|
||||
|
||||
新增、修改、删除通过 GORM 模型执行;复杂报表通过 GORM Raw 执行,并统一过滤所有关联表及子查询中的 `deleted_at`。
|
||||
删除保留原行及附件;活跃记录的唯一索引带 `WHERE deleted_at IS NULL`,删除后可以重新创建同业务键的记录。
|
||||
用户对外的六位展示编号是独立业务字段,继续使用数据库序列分配;它不是 UUID 主键。情侣空间编号为 `CP` + 不带连字符的情侣 UUID。
|
||||
|
||||
本次创建的是新的 PostgreSQL 库,不会导入旧 MySQL 数据。历史备份如需迁入,需要单独完成数据转换和旧 ID 到 UUID 的关联映射。
|
||||
|
||||
验证:`go test ./...`、`go vet ./...`;在设置 `PAST_TEST_POSTGRES_DSN` 后,可运行 `go test ./internal/store -run TestPostgresIntegration -v` 验证真实 PostgreSQL(测试数据全部回滚)。
|
||||
|
||||
以下保留历史说明,数据库配置与部署步骤以以上内容为准。
|
||||
|
||||
---
|
||||
|
||||
# Windows (amd64) 部署说明
|
||||
|
||||
后端 Go 服务通过 `//go:embed` 把 React 管理后台(`web/admin/dist`)一并打进 `past-server.exe`,
|
||||
|
||||
+28
-7
@@ -4,18 +4,22 @@ go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/minio/minio-go/v7 v7.4.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/sys v0.48.0
|
||||
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 +27,37 @@ 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/google/uuid v1.6.0 // 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/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.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
|
||||
)
|
||||
|
||||
+68
-19
@@ -1,16 +1,18 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
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 +27,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 +34,117 @@ 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/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.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/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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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/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=
|
||||
|
||||
@@ -3,26 +3,41 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
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 Config struct {
|
||||
Minio MinioConfig `yaml:"minio"`
|
||||
|
||||
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
|
||||
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
|
||||
|
||||
// DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。
|
||||
// DataDir 运行数据目录:data/ 存日志与缓存;新增上传文件保存在 MinIO。
|
||||
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
|
||||
DataDir string `yaml:"data_dir"`
|
||||
|
||||
DB struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Name string `yaml:"name"`
|
||||
User string `yaml:"user"`
|
||||
Pass string `yaml:"pass"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Name string `yaml:"name"`
|
||||
User string `yaml:"user"`
|
||||
Pass string `yaml:"pass"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
} `yaml:"db"`
|
||||
|
||||
Wechat struct {
|
||||
@@ -45,12 +60,12 @@ type Config struct {
|
||||
|
||||
// VirtualPay 微信小程序虚拟支付(米大师虚拟支付2.0)配置
|
||||
VirtualPay struct {
|
||||
AppID string `yaml:"appid"` // 小程序 AppID(默认复用 wechat.appid)
|
||||
OfferID string `yaml:"offer_id"` // 支付应用 ID
|
||||
AppKey string `yaml:"app_key"` // 正式环境 AppKey
|
||||
SandboxKey string `yaml:"sandbox_key"` // 沙箱环境 AppKey
|
||||
Env int `yaml:"env"` // 0-正式环境 1-沙箱环境
|
||||
NotifyToken string `yaml:"notify_token"` // 支付结果回调 Token(微信后台消息推送配置)
|
||||
AppID string `yaml:"appid"` // 小程序 AppID(默认复用 wechat.appid)
|
||||
OfferID string `yaml:"offer_id"` // 支付应用 ID
|
||||
AppKey string `yaml:"app_key"` // 正式环境 AppKey
|
||||
SandboxKey string `yaml:"sandbox_key"` // 沙箱环境 AppKey
|
||||
Env int `yaml:"env"` // 0-正式环境 1-沙箱环境
|
||||
NotifyToken string `yaml:"notify_token"` // 支付结果回调 Token(微信后台消息推送配置)
|
||||
NotifyAESKey string `yaml:"notify_aes_key"` // 支付结果回调 EncodingAESKey(可选,用于解密加密回调)
|
||||
} `yaml:"virtual_pay"`
|
||||
}
|
||||
@@ -58,10 +73,12 @@ type Config struct {
|
||||
func Load(path string) (*Config, error) {
|
||||
c := &Config{}
|
||||
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"
|
||||
@@ -93,10 +110,17 @@ 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") }
|
||||
|
||||
// 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,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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 != "" || c.VirtualPay.AppID != c.Wechat.AppID {
|
||||
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.VirtualPay.OfferID != "" || c.VirtualPay.SandboxKey != "" || c.Wechat.NotifyTemplateID != "" {
|
||||
t.Fatal("dev configuration must not reuse old app payment or subscription credentials")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +108,7 @@ 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,
|
||||
@@ -144,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
|
||||
}
|
||||
@@ -167,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{}
|
||||
@@ -242,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)
|
||||
@@ -303,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)
|
||||
@@ -326,7 +330,7 @@ func adminUsers(c *httpapi.Ctx) error {
|
||||
pg := paged(c, rs)
|
||||
items := pg["items"].([]store.Row)
|
||||
for _, it := range items {
|
||||
uid, cid := it.Int("id"), it.Int("couple_id")
|
||||
uid, cid := it.Str("id"), it.Str("couple_id")
|
||||
it["used_bytes"] = storageUsedBytes(c, uid, cid)
|
||||
it["storage_vip"] = isStorageVip(c, uid)
|
||||
it["storage_vip_expire_at"] = storageVipExpireAt(c, uid)
|
||||
@@ -345,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
|
||||
}
|
||||
@@ -354,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)
|
||||
@@ -365,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)
|
||||
@@ -379,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)
|
||||
@@ -434,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
|
||||
@@ -470,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)
|
||||
@@ -479,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)
|
||||
@@ -580,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)
|
||||
@@ -605,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)
|
||||
@@ -620,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)
|
||||
@@ -631,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) + "」的所有数据记录"})
|
||||
@@ -642,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 {
|
||||
@@ -656,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":
|
||||
@@ -675,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)
|
||||
}
|
||||
@@ -700,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
|
||||
}
|
||||
@@ -753,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 {
|
||||
@@ -768,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)
|
||||
@@ -780,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 {
|
||||
@@ -792,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
|
||||
}
|
||||
@@ -818,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 ""
|
||||
@@ -35,7 +35,7 @@ func coupleSpaceID(c *httpapi.Ctx, cid int64) string {
|
||||
}
|
||||
|
||||
func albumList(c *httpapi.Ctx) error {
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
photoTotal := int64(0)
|
||||
for _, album := range demoData("album") {
|
||||
@@ -49,8 +49,9 @@ func albumList(c *httpapi.Ctx) error {
|
||||
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])
|
||||
@@ -82,7 +83,7 @@ func albumAdd(c *httpapi.Ctx) error {
|
||||
}
|
||||
title := c.InStr("title")
|
||||
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
|
||||
"thing2": "TA创建了新相册,快来一起看看吧 📸",
|
||||
@@ -151,8 +152,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 := coupleUserIds(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,21 +161,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)
|
||||
}
|
||||
if err := CheckStorageQuota(c, fh.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -193,7 +194,7 @@ func photos(c *httpapi.Ctx) error {
|
||||
if albumID == "" {
|
||||
albumID = "0"
|
||||
}
|
||||
ids := coupleUserIds(c, 0)
|
||||
ids := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
return listResponse(c, nil, 0, nil)
|
||||
}
|
||||
@@ -217,14 +218,13 @@ 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 {
|
||||
if cid == "" {
|
||||
return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound)
|
||||
}
|
||||
spaceID := coupleSpaceID(c, cid)
|
||||
albumID := c.Form("album_id")
|
||||
if albumID == "" {
|
||||
albumID = "0"
|
||||
@@ -237,45 +237,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")
|
||||
}
|
||||
// 存储配额检查(照片是存储大头,超限引导付费)
|
||||
var pendingBytes int64
|
||||
if len(files) > 0 {
|
||||
for _, fh := range files {
|
||||
pendingBytes += fh.Size
|
||||
}
|
||||
} else {
|
||||
pendingBytes = int64(len(c.Body()))
|
||||
}
|
||||
if err := CheckStorageQuota(c, pendingBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
// 兼容某些安卓端直接传二进制:从原始请求体读取
|
||||
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)
|
||||
uploaded = append(uploaded, res.Path)
|
||||
setFirstCover(c, albumID, res.Path)
|
||||
} else if strings.Contains(res.Error, "违规") {
|
||||
errors = append(errors, "所发布内容含违规信息")
|
||||
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 {
|
||||
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 {
|
||||
@@ -283,11 +289,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 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "相册有更新",
|
||||
"thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)),
|
||||
@@ -296,7 +305,11 @@ 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 {
|
||||
@@ -313,8 +326,7 @@ func photoDelete(c *httpapi.Ctx) error {
|
||||
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") {
|
||||
@@ -357,7 +369,7 @@ func photoAdd(c *httpapi.Ctx) error {
|
||||
}
|
||||
setFirstCover(c, albumID, c.InStr("path"))
|
||||
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "")
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "相册有更新",
|
||||
"thing2": "TA上传了新照片 📸",
|
||||
@@ -377,8 +389,8 @@ func photoUpdate(c *httpapi.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := c.InInt("id")
|
||||
if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
|
||||
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)
|
||||
@@ -396,7 +408,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,606 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"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)
|
||||
}
|
||||
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}
|
||||
wx := wechat.New("test-app", "test-secret", "test-template", dataDir, 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 }()
|
||||
oldLog := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
defer slog.SetDefault(oldLog)
|
||||
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 {
|
||||
if action == "storage_pay_notify" {
|
||||
return M{"body": out.Body.String()}
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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("admin_login", "", M{"username": "admin", "password": "admin123"})
|
||||
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"}, 400)
|
||||
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": "admin123", "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, "month_product_id": "month-test", "year_product_id": "year-test", "pay_offer_id": "test-offer", "pay_app_key": "test-key", "pay_sandbox_key": "test-sandbox", "pay_env": 1})
|
||||
if _, err := db.Exec("UPDATE users SET session_key=? WHERE id=?", "test-session", aID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := post("storage_create_order", aToken, M{"plan_key": "month"})
|
||||
yearOrder := post("storage_order_create", aToken, M{"plan_key": "year"})
|
||||
// Simultaneous duplicate delivery must grant one month exactly once.
|
||||
var before model.User
|
||||
db.ORM.First(&before, "id=?", aID)
|
||||
errors := make(chan error, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
go func() { errors <- deliverStorageOrder(db, store.ToStr(order["out_trade_no"]), "wx-order") }()
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := <-errors; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var after model.User
|
||||
db.ORM.First(&after, "id=?", aID)
|
||||
expectedExpiry := before.StorageVipExpireAt.AddDate(0, 1, 0)
|
||||
if after.StorageVipExpireAt == nil || !after.StorageVipExpireAt.Equal(expectedExpiry) {
|
||||
t.Fatalf("duplicate payment extended membership more than once: before=%v after=%v", before.StorageVipExpireAt, after.StorageVipExpireAt)
|
||||
}
|
||||
// Exercise the actual XML delivery callback twice; verify its persisted effects.
|
||||
notifyXML := "<xml><Event>xpay_goods_deliver_notify</Event><OutTradeNo>" + store.ToStr(yearOrder["out_trade_no"]) + "</OutTradeNo><WeChatPayInfo><MchOrderNo>wx-year-order</MchOrderNo></WeChatPayInfo></xml>"
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/storage_pay_notify", strings.NewReader(notifyXML))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
out := httptest.NewRecorder()
|
||||
engine.ServeHTTP(out, req)
|
||||
covered["storage_pay_notify"]++
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), "<ErrCode>0</ErrCode>") {
|
||||
t.Fatalf("delivery callback: %d %s", out.Code, out.Body.String())
|
||||
}
|
||||
happy["storage_pay_notify"] = true
|
||||
}
|
||||
var yearUser model.User
|
||||
db.ORM.First(&yearUser, "id=?", aID)
|
||||
if yearUser.StorageVipExpireAt == nil || !yearUser.StorageVipExpireAt.Equal(expectedExpiry.AddDate(1, 0, 0)) {
|
||||
t.Fatalf("year callback granted incorrect membership: %v", yearUser.StorageVipExpireAt)
|
||||
}
|
||||
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)
|
||||
post("diary_update", otherToken, M{"id": foreignID, "title": "intrusion", "content": "intrusion"})
|
||||
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 != 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 != 307 {
|
||||
t.Fatalf("file access endpoint: %d", fileResponse.Code)
|
||||
}
|
||||
realClient := &http.Client{Transport: oldTransport, Timeout: 15 * time.Second}
|
||||
download, err := realClient.Get(fileResponse.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
download.Body.Close()
|
||||
if download.StatusCode != 200 {
|
||||
t.Fatalf("shared object download failed: %d", download.StatusCode)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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, "storage_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
|
||||
}
|
||||
@@ -66,8 +66,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"
|
||||
@@ -155,7 +155,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})
|
||||
@@ -173,7 +173,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") {
|
||||
@@ -212,8 +212,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
|
||||
@@ -236,15 +236,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") {
|
||||
@@ -302,7 +302,7 @@ func checkinDo(c *httpapi.Ctx) error {
|
||||
title := item.Str("title")
|
||||
actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "")
|
||||
c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note)
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
|
||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||
"thing1": "打卡「" + mbTruncate(title, 20) + "」",
|
||||
"thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak),
|
||||
@@ -318,8 +318,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)
|
||||
@@ -327,7 +327,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 {
|
||||
@@ -337,12 +337,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])
|
||||
@@ -372,7 +372,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")
|
||||
@@ -389,21 +389,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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -416,7 +419,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)
|
||||
@@ -452,7 +455,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)
|
||||
@@ -72,48 +71,35 @@ 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 mx != nil && mx.Int("max_id") != 0 {
|
||||
next = mx.Int("max_id") + 1
|
||||
} else {
|
||||
next = 520001
|
||||
}
|
||||
if row == nil {
|
||||
return "", fmt.Errorf("用户编号生成失败")
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -121,28 +107,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)
|
||||
@@ -153,7 +139,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)
|
||||
@@ -161,11 +147,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)
|
||||
@@ -173,7 +155,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...)
|
||||
@@ -182,18 +164,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{
|
||||
{"日常甜蜜", "我们的小日常"},
|
||||
{"旅行记录", "一起走过的风景"},
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 := coupleUserIds(c, "")
|
||||
if len(ids) == 0 {
|
||||
demo := demoData("diary")
|
||||
return c.OK(M{"items": demo, "total": len(demo)})
|
||||
@@ -145,7 +145,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
|
||||
@@ -214,8 +214,8 @@ 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)
|
||||
@@ -243,8 +243,8 @@ 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)
|
||||
@@ -271,12 +271,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 +289,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 +306,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 +330,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,27 +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 pendingBytes int64
|
||||
for _, f := range []string{"file", "avatar", "photo", "image"} {
|
||||
if fh := c.File(f); fh != nil {
|
||||
pendingBytes = fh.Size
|
||||
break
|
||||
}
|
||||
}
|
||||
if pendingBytes == 0 {
|
||||
pendingBytes = int64(len(c.Body()))
|
||||
}
|
||||
if err := CheckStorageQuota(c, pendingBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
var res *upload.Result
|
||||
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
|
||||
}
|
||||
@@ -70,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
|
||||
}
|
||||
}
|
||||
@@ -83,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
|
||||
}
|
||||
@@ -91,12 +81,12 @@ 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"
|
||||
@@ -114,10 +104,9 @@ func setBgImage(c *httpapi.Ctx) error {
|
||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||
}
|
||||
cid := c.CoupleID()
|
||||
if cid == 0 {
|
||||
if cid == "" {
|
||||
return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound)
|
||||
}
|
||||
couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers")
|
||||
bgKey, verKey := bgKeys(cid)
|
||||
nextVersion := func() int64 {
|
||||
if old, ok := settingValue(c, verKey); ok {
|
||||
@@ -138,24 +127,19 @@ func setBgImage(c *httpapi.Ctx) error {
|
||||
if fh == nil {
|
||||
fh = c.File("file")
|
||||
}
|
||||
var pendingBytes int64
|
||||
if fh != nil {
|
||||
pendingBytes = fh.Size
|
||||
} else {
|
||||
pendingBytes = int64(len(c.Body()))
|
||||
}
|
||||
if err := CheckStorageQuota(c, pendingBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if fh != nil {
|
||||
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)
|
||||
}
|
||||
@@ -173,10 +157,7 @@ func deleteBgImage(c *httpapi.Ctx) error {
|
||||
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 {
|
||||
if _, err := c.DB.Exec("DELETE FROM settings WHERE key = ? OR key = ?", bgKey, verKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(M{"success": true})
|
||||
@@ -190,7 +171,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
|
||||
}
|
||||
@@ -257,7 +238,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{})
|
||||
}
|
||||
@@ -283,7 +264,7 @@ func subscribeTemplate(c *httpapi.Ctx) error {
|
||||
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})
|
||||
|
||||
@@ -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 {
|
||||
@@ -167,7 +167,7 @@ func periodList(c *httpapi.Ctx) error {
|
||||
cy["end_date"] = last
|
||||
}
|
||||
}
|
||||
return c.OK(M{"cycles": cycleRows, "daily": dailyRows, "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0})
|
||||
return c.OK(M{"cycles": cycleRows, "daily": dailyRows, "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != ""})
|
||||
}
|
||||
|
||||
func periodAdd(c *httpapi.Ctx) error {
|
||||
@@ -206,7 +206,7 @@ func periodAdd(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) {
|
||||
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil {
|
||||
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON CONFLICT (record_date, couple_id) WHERE deleted_at IS NULL DO UPDATE SET period = EXCLUDED.period", cid, uid, d.Format("2006-01-02")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -214,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": "经期记录有更新",
|
||||
@@ -230,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, "")
|
||||
@@ -244,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 {
|
||||
@@ -331,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
|
||||
}
|
||||
}
|
||||
@@ -347,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
|
||||
@@ -379,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 {
|
||||
@@ -439,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
|
||||
}
|
||||
}
|
||||
@@ -458,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
|
||||
}
|
||||
@@ -529,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": "点击查看详情",
|
||||
@@ -545,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,7 +54,7 @@ func smokingDelta(kind string, quantity, stock int64) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
|
||||
func smokingSnapshot(c *httpapi.Ctx, uid string) (M, error) {
|
||||
result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "card_visible": int64(1), "available": true}
|
||||
// 抽烟卡片显示/隐藏偏好:始终返回“当前登录用户”自己的设置(与查看对象无关),前端缓存失效后仍能恢复。
|
||||
if me, err := c.DB.QueryOne("SELECT smoking_card_visible FROM users WHERE id = ?", c.UID()); err != nil {
|
||||
@@ -81,13 +81,13 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
|
||||
if account == nil {
|
||||
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
|
||||
@@ -106,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
|
||||
}
|
||||
@@ -122,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) {
|
||||
@@ -168,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, "参数不正确")
|
||||
@@ -192,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)
|
||||
@@ -202,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
|
||||
@@ -271,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": "戒烟打卡",
|
||||
@@ -283,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
|
||||
|
||||
@@ -37,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)
|
||||
|
||||
@@ -5,11 +5,8 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"past/internal/config"
|
||||
@@ -20,16 +17,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
settingFreeMB = "storage_free_mb"
|
||||
settingMonthPrice = "storage_plan_month_price"
|
||||
settingMonthProduct = "storage_plan_month_product"
|
||||
settingYearPrice = "storage_plan_year_price"
|
||||
settingYearProduct = "storage_plan_year_product"
|
||||
settingPayMode = "storage_pay_mode"
|
||||
settingPayOfferID = "storage_pay_offer_id"
|
||||
settingPayAppKey = "storage_pay_app_key"
|
||||
settingFreeMB = "storage_free_mb"
|
||||
settingMonthPrice = "storage_plan_month_price"
|
||||
settingMonthProduct = "storage_plan_month_product"
|
||||
settingYearPrice = "storage_plan_year_price"
|
||||
settingYearProduct = "storage_plan_year_product"
|
||||
settingPayMode = "storage_pay_mode"
|
||||
settingPayOfferID = "storage_pay_offer_id"
|
||||
settingPayAppKey = "storage_pay_app_key"
|
||||
settingPaySandboxKey = "storage_pay_sandbox_key"
|
||||
settingPayEnv = "storage_pay_env"
|
||||
settingPayEnv = "storage_pay_env"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +51,7 @@ func registerStorage(r *httpapi.Router) {
|
||||
}
|
||||
|
||||
func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 {
|
||||
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
|
||||
row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") || row.Str("value") == "" {
|
||||
return def
|
||||
}
|
||||
@@ -62,7 +59,7 @@ func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 {
|
||||
}
|
||||
|
||||
func storageSettingStr(c *httpapi.Ctx, key string, def string) string {
|
||||
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
|
||||
row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") {
|
||||
return def
|
||||
}
|
||||
@@ -72,7 +69,7 @@ func storageSettingStr(c *httpapi.Ctx, key string, def string) string {
|
||||
// storagePaySetting 读取支付通道配置:settings 中有非空值则使用,否则回退到 config.yaml 默认值。
|
||||
// 与 storageSettingStr 不同,空字符串在此视为“未设置”,避免后台清空后误覆盖 config 默认。
|
||||
func storagePaySetting(c *httpapi.Ctx, key, def string) string {
|
||||
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
|
||||
row, _ := c.DB.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") || row.Str("value") == "" {
|
||||
return def
|
||||
}
|
||||
@@ -88,87 +85,38 @@ func vpayClient(c *httpapi.Ctx) *virtualpay.Client {
|
||||
return virtualpay.New(offerID, appKey, sandboxKey, env)
|
||||
}
|
||||
|
||||
func dirSize(root string) int64 {
|
||||
var total int64
|
||||
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
// storageUsedTotalCache 缓存全站已用存储总量,避免后台统计页频繁整目录遍历。
|
||||
var storageUsedTotalCache = struct {
|
||||
sync.RWMutex
|
||||
bytes int64
|
||||
at time.Time
|
||||
}{}
|
||||
|
||||
// allStorageUsedBytes 统计存储目录所有文件占用字节数,即全站所有用户已使用的存储空间总量。
|
||||
// 注意:按文件系统实际占用计算(情侣共享空间只计一次),更准确反映磁盘消耗。结果缓存 60 秒。
|
||||
// Count deduplicated MinIO objects, not directories on the API server.
|
||||
func allStorageUsedBytes() int64 {
|
||||
storageUsedTotalCache.RLock()
|
||||
if !storageUsedTotalCache.at.IsZero() && time.Since(storageUsedTotalCache.at) < time.Minute {
|
||||
v := storageUsedTotalCache.bytes
|
||||
storageUsedTotalCache.RUnlock()
|
||||
return v
|
||||
if up == nil || up.DB == nil {
|
||||
return 0
|
||||
}
|
||||
storageUsedTotalCache.RUnlock()
|
||||
|
||||
root := up.Dir
|
||||
var total int64
|
||||
if root != "" {
|
||||
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
|
||||
if err == nil && info != nil && !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
size, err := store.UsedFileBytes(up.DB.ORM, nil)
|
||||
if err != nil {
|
||||
slog.Error("读取存储用量失败", "err", err)
|
||||
}
|
||||
storageUsedTotalCache.Lock()
|
||||
storageUsedTotalCache.bytes = total
|
||||
storageUsedTotalCache.at = time.Now()
|
||||
storageUsedTotalCache.Unlock()
|
||||
return total
|
||||
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 {
|
||||
slog.Error("读取用户存储用量失败", "err", err)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// storageUsedBytes 统计当前用户(或其情侣空间)已用存储字节数。
|
||||
// 组成:情侣空间共享文件(couples/{space})+ 相关用户(本人,或情侣双方)的头像(avatars/{user_id})与个人上传文件(users/{id})。
|
||||
func storageUsedBytes(c *httpapi.Ctx, uid, coupleID int64) int64 {
|
||||
root := up.Dir
|
||||
var total int64
|
||||
if coupleID != 0 {
|
||||
if space := coupleSpaceID(c, coupleID); space != "" {
|
||||
total += dirSize(filepath.Join(root, "couples", space))
|
||||
}
|
||||
}
|
||||
ids := []int64{uid}
|
||||
if coupleID != 0 {
|
||||
if cids := coupleUserIds(c, coupleID); len(cids) > 0 {
|
||||
ids = cids
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", id); u != nil {
|
||||
total += dirSize(filepath.Join(root, "avatars", u.Str("user_id")))
|
||||
}
|
||||
total += dirSize(filepath.Join(root, "users", store.ToStr(id)))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func storageVipUserIDs(c *httpapi.Ctx, uid int64) []int64 {
|
||||
ids := []int64{uid}
|
||||
if p := partnerUserId(c.DB, uid); p != 0 {
|
||||
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 int64) bool {
|
||||
func isStorageVip(c *httpapi.Ctx, uid string) bool {
|
||||
for _, id := range storageVipUserIDs(c, uid) {
|
||||
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id)
|
||||
if row != nil {
|
||||
@@ -178,7 +126,7 @@ func isStorageVip(c *httpapi.Ctx, uid int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func storageVipExpireAt(c *httpapi.Ctx, uid int64) string {
|
||||
func storageVipExpireAt(c *httpapi.Ctx, uid string) string {
|
||||
var latest string
|
||||
for _, id := range storageVipUserIDs(c, uid) {
|
||||
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id)
|
||||
@@ -191,8 +139,8 @@ func storageVipExpireAt(c *httpapi.Ctx, uid int64) string {
|
||||
|
||||
// storageQuotaMB 返回用户实际可用的免费存储容量(MB):
|
||||
// 优先使用后台手动设置的自定义容量,否则回退到全局默认免费额度。
|
||||
func storageQuotaMB(c *httpapi.Ctx, uid int64) int64 {
|
||||
if uid != 0 {
|
||||
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")
|
||||
}
|
||||
@@ -200,25 +148,9 @@ func storageQuotaMB(c *httpapi.Ctx, uid int64) int64 {
|
||||
return storageSettingInt(c, settingFreeMB, defaultFreeMB)
|
||||
}
|
||||
|
||||
// CheckStorageQuota 上传前检查存储配额;extraBytes 为本次待写入字节数。
|
||||
func CheckStorageQuota(c *httpapi.Ctx, extraBytes int64) error {
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
return nil
|
||||
}
|
||||
if isStorageVip(c, uid) {
|
||||
return nil
|
||||
}
|
||||
freeBytes := storageQuotaMB(c, uid) * 1024 * 1024
|
||||
if storageUsedBytes(c, uid, c.CoupleID())+extraBytes > freeBytes {
|
||||
return c.ErrCode(403, "存储空间不足,请升级为无限空间会员", ErrStorageQuotaExceeded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storageInfo(c *httpapi.Ctx) error {
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
coupleID := c.CoupleID()
|
||||
@@ -229,12 +161,12 @@ func storageInfo(c *httpapi.Ctx) error {
|
||||
{"key": "year", "name": "包年无限空间", "price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), "product_id": storageSettingStr(c, settingYearProduct, "")},
|
||||
}
|
||||
resp := M{
|
||||
"used_bytes": storageUsedBytes(c, uid, coupleID),
|
||||
"free_mb": freeMB,
|
||||
"free_bytes": freeMB * 1024 * 1024,
|
||||
"is_vip": isStorageVip(c, uid),
|
||||
"used_bytes": storageUsedBytes(c, uid, coupleID),
|
||||
"free_mb": freeMB,
|
||||
"free_bytes": freeMB * 1024 * 1024,
|
||||
"is_vip": isStorageVip(c, uid),
|
||||
"vip_expire_at": nil,
|
||||
"plans": plans,
|
||||
"plans": plans,
|
||||
}
|
||||
if vipExpire != "" {
|
||||
resp["vip_expire_at"] = vipExpire
|
||||
@@ -247,7 +179,7 @@ func storageOrderCreate(c *httpapi.Ctx) error {
|
||||
return err
|
||||
}
|
||||
uid := c.UID()
|
||||
if uid == 0 {
|
||||
if uid == "" {
|
||||
return c.Err(401, "请先登录")
|
||||
}
|
||||
planKey := c.InTrim("plan_key")
|
||||
@@ -282,7 +214,7 @@ func storageOrderCreate(c *httpapi.Ctx) error {
|
||||
return c.Err(500, "生成支付参数失败")
|
||||
}
|
||||
var cid any
|
||||
if cidv := c.CoupleID(); cidv != 0 {
|
||||
if cidv := c.CoupleID(); cidv != "" {
|
||||
cid = cidv
|
||||
}
|
||||
if _, err := c.DB.Insert("INSERT INTO storage_orders (user_id, couple_id, out_trade_no, plan_key, product_id, amount_cents, env, status, attach) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)",
|
||||
@@ -307,14 +239,14 @@ func storageOrderCreate(c *httpapi.Ctx) error {
|
||||
|
||||
// xpayNotify 道具发货推送(xpay_goods_deliver_notify)的 XML 消息结构。
|
||||
type xpayNotify struct {
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime string `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Event string `xml:"Event"`
|
||||
OpenId string `xml:"OpenId"`
|
||||
OutTradeNo string `xml:"OutTradeNo"`
|
||||
Env string `xml:"Env"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime string `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Event string `xml:"Event"`
|
||||
OpenId string `xml:"OpenId"`
|
||||
OutTradeNo string `xml:"OutTradeNo"`
|
||||
Env string `xml:"Env"`
|
||||
WeChatPayInfo struct {
|
||||
MchOrderNo string `xml:"MchOrderNo"`
|
||||
} `xml:"WeChatPayInfo"`
|
||||
@@ -370,27 +302,37 @@ func notifyReply(c *httpapi.Ctx, errCode int, errMsg string) error {
|
||||
// deliverStorageOrder 按订单号发货:标记支付成功并为用户开通/续期会员。
|
||||
// 注意:该函数不直接向客户端写响应,而是返回 error 供调用方统一应答,避免污染微信回调的 XML 响应。
|
||||
func deliverStorageOrder(db *store.DB, outTradeNo, wxOrderID string) error {
|
||||
order, _ := db.QueryOne("SELECT id, user_id, plan_key, status FROM storage_orders WHERE out_trade_no = ?", outTradeNo)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
order, err := tx.QueryOne("SELECT id,user_id,plan_key,status FROM storage_orders WHERE out_trade_no=? FOR UPDATE", outTradeNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order == nil {
|
||||
return fmt.Errorf("订单不存在: %s", outTradeNo)
|
||||
}
|
||||
if order.Str("status") == "success" {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec("UPDATE storage_orders SET status = 'success', wx_order_id = ?, paid_at = NOW() WHERE id = ?", wxOrderID, order.Int("id")); err != nil {
|
||||
return err
|
||||
return tx.Commit()
|
||||
}
|
||||
months := 1
|
||||
if order.Str("plan_key") == "year" {
|
||||
months = 12
|
||||
}
|
||||
_, err := db.Exec("UPDATE users SET storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", months, order.Int("user_id"))
|
||||
return err
|
||||
if _, err := tx.Exec("UPDATE users SET storage_vip_expire_at=(COALESCE(storage_vip_expire_at,NOW()) + (? * INTERVAL '1 month')) WHERE id=?", months, order.Str("user_id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("UPDATE storage_orders SET status='success',wx_order_id=?,paid_at=NOW() WHERE id=?", wxOrderID, order.Str("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// storageSettingIntDB / storagePaySettingDB:定时任务无请求上下文,直接以 DB 读取设置。
|
||||
func storageSettingIntDB(db *store.DB, key string, def int64) int64 {
|
||||
row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
|
||||
row, _ := db.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") || row.Str("value") == "" {
|
||||
return def
|
||||
}
|
||||
@@ -398,7 +340,7 @@ func storageSettingIntDB(db *store.DB, key string, def int64) int64 {
|
||||
}
|
||||
|
||||
func storagePaySettingDB(db *store.DB, key, def string) string {
|
||||
row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
|
||||
row, _ := db.QueryOne("SELECT value FROM settings WHERE key = ?", key)
|
||||
if row == nil || row.IsNull("value") || row.Str("value") == "" {
|
||||
return def
|
||||
}
|
||||
@@ -432,7 +374,7 @@ func storageOrderQueryOnce(db *store.DB, wx *wechat.Client, cfg *config.Config)
|
||||
return
|
||||
}
|
||||
// 只补最近 24 小时内的 pending 订单;env 与当前配置一致才可查单。
|
||||
orders, err := db.Query("SELECT user_id, out_trade_no FROM storage_orders WHERE status = 'pending' AND env = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY id DESC LIMIT 50", env)
|
||||
orders, err := db.Query("SELECT user_id, out_trade_no FROM storage_orders WHERE status = 'pending' AND env = ? AND created_at > (NOW() - INTERVAL '24 hour') ORDER BY created_at DESC, id DESC LIMIT 50", env)
|
||||
if err != nil || len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -441,7 +383,7 @@ func storageOrderQueryOnce(db *store.DB, wx *wechat.Client, cfg *config.Config)
|
||||
return
|
||||
}
|
||||
for _, o := range orders {
|
||||
openid := userOpenId(db, o.Int("user_id"))
|
||||
openid := userOpenId(db, o.Str("user_id"))
|
||||
if openid == "" {
|
||||
continue
|
||||
}
|
||||
@@ -540,9 +482,12 @@ func adminStorageUsers(c *httpapi.Ctx) error {
|
||||
}
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
if isNumeric(search) {
|
||||
where = append(where, "(id = ? OR user_id = ? OR nickname LIKE ? OR username LIKE ?)")
|
||||
params = append(params, store.ToInt(search), store.ToInt(search), like, like)
|
||||
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)
|
||||
@@ -558,7 +503,7 @@ func adminStorageUsers(c *httpapi.Ctx) error {
|
||||
today := now()
|
||||
out := make([]store.Row, 0, len(rs))
|
||||
for _, u := range rs {
|
||||
uid, cid := u.Int("id"), u.Int("couple_id")
|
||||
uid, cid := u.Str("id"), u.Str("couple_id")
|
||||
expire := u.Str("storage_vip_expire_at")
|
||||
quota := u.Int("storage_quota_mb")
|
||||
u["used_bytes"] = storageUsedBytes(c, uid, cid)
|
||||
@@ -596,8 +541,8 @@ func adminStorageUserUpdate(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无效")
|
||||
}
|
||||
user, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ?", userID)
|
||||
@@ -625,7 +570,7 @@ func adminStorageUserUpdate(c *httpapi.Ctx) error {
|
||||
}
|
||||
expire := c.InTrim("vip_expire_at")
|
||||
if expire == "" {
|
||||
if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", planKey, months, userID); err != nil {
|
||||
if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = (COALESCE(storage_vip_expire_at, NOW()) + (? * INTERVAL '1 month')) WHERE id = ?", planKey, months, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -692,7 +637,7 @@ func adminStorageOrders(c *httpapi.Ctx) error {
|
||||
if len(where) > 0 {
|
||||
sql += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
sql += " ORDER BY o.id DESC"
|
||||
sql += " ORDER BY o.created_at DESC, o.id DESC"
|
||||
rs, err := c.DB.Query(sql, params...)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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 := coupleUserIds(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
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func timelineAdd(c *httpapi.Ctx) error {
|
||||
}
|
||||
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) + "」",
|
||||
|
||||
@@ -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})
|
||||
@@ -258,7 +246,7 @@ 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 != "" {
|
||||
@@ -274,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"
|
||||
@@ -330,15 +318,15 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +338,7 @@ func checkAuth(c *httpapi.Ctx) error {
|
||||
}
|
||||
|
||||
func logout(c *httpapi.Ctx) error {
|
||||
if uid := c.UID(); uid != 0 {
|
||||
if uid := c.UID(); uid != "" {
|
||||
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
|
||||
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
|
||||
}
|
||||
@@ -366,8 +354,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
|
||||
}
|
||||
@@ -436,7 +424,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, "、"), "")
|
||||
}
|
||||
}
|
||||
@@ -475,7 +463,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"]))
|
||||
@@ -528,12 +516,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, "无法修改此用户信息")
|
||||
}
|
||||
@@ -545,7 +533,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")
|
||||
@@ -589,7 +577,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, "、"), "")
|
||||
}
|
||||
}
|
||||
@@ -597,12 +585,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")
|
||||
@@ -610,13 +593,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)
|
||||
}
|
||||
@@ -639,7 +626,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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -100,8 +100,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 +113,7 @@ const adminCookieName = "PHPSESSID"
|
||||
const adminSessionTTL = 12 * time.Hour
|
||||
|
||||
type adminSession struct {
|
||||
AdminID int64
|
||||
AdminID string
|
||||
Username string
|
||||
Expires time.Time
|
||||
}
|
||||
@@ -162,7 +162,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 +189,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,6 +16,7 @@ import (
|
||||
"past/internal/config"
|
||||
"past/internal/geo"
|
||||
"past/internal/store"
|
||||
"past/internal/upload"
|
||||
"past/internal/wechat"
|
||||
)
|
||||
|
||||
@@ -25,27 +26,29 @@ type App struct {
|
||||
DB *store.DB
|
||||
Wechat *wechat.Client
|
||||
Geo *geo.Client
|
||||
Files *upload.Uploader
|
||||
Admin *AdminSessions
|
||||
}
|
||||
|
||||
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
|
||||
type Ctx struct {
|
||||
G *gin.Context
|
||||
W http.ResponseWriter
|
||||
R *http.Request
|
||||
App *App
|
||||
DB *store.DB
|
||||
Action string
|
||||
CamelCase bool
|
||||
Method string
|
||||
UploadResult *upload.Result
|
||||
G *gin.Context
|
||||
W http.ResponseWriter
|
||||
R *http.Request
|
||||
App *App
|
||||
DB *store.DB
|
||||
Action string
|
||||
CamelCase bool
|
||||
Method string
|
||||
|
||||
input map[string]any
|
||||
inputParsed bool
|
||||
body []byte
|
||||
bodyRead bool
|
||||
uid int64
|
||||
uid string
|
||||
uidResolved bool
|
||||
coupleID int64
|
||||
coupleID string
|
||||
coupleResolved bool
|
||||
responded bool
|
||||
}
|
||||
@@ -65,10 +68,19 @@ func (c *Ctx) JSON(code int, v any) error {
|
||||
return errResponded
|
||||
}
|
||||
c.responded = true
|
||||
if c.uidResolved && c.uid != 0 && c.coupleResolved {
|
||||
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)
|
||||
@@ -172,6 +184,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"past/internal/upload"
|
||||
)
|
||||
|
||||
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
|
||||
@@ -41,6 +42,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))
|
||||
@@ -54,7 +57,7 @@ func (r *Router) Actions() []string {
|
||||
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
|
||||
"check_invite_code", "register_by_invite", "check_auth", "storage_pay_notify")
|
||||
|
||||
var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders")
|
||||
var adminActions = set("admin_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))
|
||||
@@ -79,6 +82,19 @@ func (r *Router) Engine(debugMode bool) *gin.Engine {
|
||||
e.MaxMultipartMemory = 32 << 20
|
||||
|
||||
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
|
||||
e.GET("/api/files/:fileId", func(c *gin.Context) {
|
||||
if r.app.Files == nil {
|
||||
c.Status(503)
|
||||
return
|
||||
}
|
||||
signed, err := r.app.Files.PresignedURL(c.Request.Context(), c.Param("fileId"))
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, signed)
|
||||
})
|
||||
e.Any("/api", r.serveAPI)
|
||||
e.Any("/api/", r.serveAPI)
|
||||
e.Any("/api/:action", r.serveAPI)
|
||||
@@ -179,6 +195,9 @@ func (r *Router) serveAPI(g *gin.Context) {
|
||||
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, "未知操作")
|
||||
|
||||
@@ -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,39 @@
|
||||
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:"-"`
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// 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"`
|
||||
StoragePlan string `gorm:"column:storage_plan;type:varchar(20);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;not null" 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;not null" 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;not null" 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 StorageOrder struct {
|
||||
BaseModel
|
||||
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(20);not null" json:"plan_key"`
|
||||
ProductId string `gorm:"column:product_id;type:varchar(64);not null;default:''" json:"product_id"`
|
||||
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
|
||||
Env int64 `gorm:"column:env;type:bigint;not null;default:1" json:"env"`
|
||||
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{&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{}, &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{}, "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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。
|
||||
@@ -96,16 +96,16 @@ func Once(db *store.DB, wx Sender, force bool) {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
|
||||
type testSender struct {
|
||||
calls int
|
||||
to, from int64
|
||||
to, from 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
|
||||
return true
|
||||
@@ -56,12 +56,12 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+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,363 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
var vip int64
|
||||
if err := db.Model(&model.User{}).Where("id IN ? AND storage_vip_expire_at>NOW()", owners).Count(&vip).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if vip > 0 {
|
||||
return nil
|
||||
}
|
||||
var user model.User
|
||||
if err := db.First(&user, "id=?", userId).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
limitMB := user.StorageQuotaMb
|
||||
if limitMB <= 0 {
|
||||
var setting model.Setting
|
||||
result := db.Where("key=?", "storage_free_mb").Find(&setting)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
limitMB = 200
|
||||
if result.RowsAffected > 0 && setting.Value != "" {
|
||||
limitMB = ToInt(setting.Value)
|
||||
if limitMB < 0 {
|
||||
limitMB = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
used, err := UsedFileBytes(db, owners)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if used+file.Size > limitMB*1024*1024 {
|
||||
return ErrUploadQuota
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func GrantFile(db *gorm.DB, userId, fileId string) error {
|
||||
if userId == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.FileUpload{UserId: userId, FileId: fileId}).Error
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"gorm.io/gorm"
|
||||
"past/internal/model"
|
||||
)
|
||||
|
||||
// Opt in with PAST_TEST_POSTGRES_DSN. All test data is rolled back.
|
||||
func TestPostgresIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set PAST_TEST_POSTGRES_DSN for real PostgreSQL verification")
|
||||
}
|
||||
db, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var oldTableCount, newTableCount int64
|
||||
if err := db.ORM.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND table_name LIKE 'past-%'").Scan(&oldTableCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ORM.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND left(table_name,5)='past_'").Scan(&newTableCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldTableCount != 0 || newTableCount != int64(len(model.Models())) {
|
||||
t.Fatalf("table prefix migration incomplete: old=%d new=%d", oldTableCount, newTableCount)
|
||||
}
|
||||
t.Logf("verified %d past_ tables and no past- tables", newTableCount)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
exec := func(q string, a ...any) {
|
||||
t.Helper()
|
||||
if _, e := tx.Exec(q, a...); e != nil {
|
||||
t.Fatalf("%s: %v", q, e)
|
||||
}
|
||||
}
|
||||
insert := func(q string, a ...any) string {
|
||||
t.Helper()
|
||||
id, e := tx.Insert(q, a...)
|
||||
if e != nil {
|
||||
t.Fatalf("%s: %v", q, e)
|
||||
}
|
||||
var uuid pgtype.UUID
|
||||
if e = uuid.Scan(id); e != nil || len(id) != 36 {
|
||||
t.Fatalf("invalid UUID %q: %v", id, e)
|
||||
}
|
||||
return id
|
||||
}
|
||||
user := insert("INSERT INTO users (user_id,username,nickname) VALUES (?,?,?)", "i"+time.Now().Format("150405.000000000"), "i"+time.Now().Format("150405.000000000"), "test")
|
||||
second := insert("INSERT INTO users (user_id,username) VALUES (?,?)", "s"+user[:8], "second-"+user)
|
||||
couple := insert("INSERT INTO couples (user1_id,user2_id,couple_space_id) VALUES (?,?,?)", user, second, "CP"+strings.ReplaceAll(user, "-", ""))
|
||||
exec("UPDATE users SET couple_id = ? WHERE id IN (?,?)", couple, user, second)
|
||||
album := insert("INSERT INTO albums (couple_id,title,created_by) VALUES (?,?,?)", couple, "test", user)
|
||||
file := model.File{Size: 1, Suffix: "png", ContentType: "image/png", ObjectKey: "integration/" + user + ".png", Md5: strings.ReplaceAll(user, "-", ""), Sha256: strings.Repeat("a", 64), Url: "https://example.test/" + user, Bucket: "integration"}
|
||||
if err := tx.ORM.Create(&file).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
photo := insert("INSERT INTO photos (album_id,path,created_by) VALUES (?,?,?)", album, file.Id, user)
|
||||
row, e := tx.QueryOne("SELECT p.*,a.title,u.nickname FROM photos p LEFT JOIN albums a ON a.id=p.album_id LEFT JOIN users u ON u.id=p.created_by WHERE p.id=?", photo)
|
||||
if e != nil || row == nil || row.Str("nickname") != "test" {
|
||||
t.Fatalf("join: %v %v", row, e)
|
||||
}
|
||||
if _, e := tx.QueryOne("SELECT id FROM couples WHERE id=? FOR UPDATE", couple); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
exec("UPDATE users SET storage_vip_expire_at = (COALESCE(storage_vip_expire_at,NOW()) + (? * INTERVAL '1 month')) WHERE id=?", 1, user)
|
||||
exec("INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value=EXCLUDED.value", "integration-"+user, "one")
|
||||
exec("INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT (key) WHERE deleted_at IS NULL DO UPDATE SET value=EXCLUDED.value", "integration-"+user, "two")
|
||||
row, e = tx.QueryOne("SELECT value FROM settings WHERE key=?", "integration-"+user)
|
||||
if e != nil || row.Str("value") != "two" {
|
||||
t.Fatalf("upsert: %v %v", row, e)
|
||||
}
|
||||
exec("DELETE FROM settings WHERE key=?", "integration-"+user)
|
||||
exec("INSERT INTO settings (key,value) VALUES (?,?)", "integration-"+user, "three")
|
||||
exec("INSERT INTO smoking_wallets (user_id) VALUES (?) ON CONFLICT DO NOTHING", user)
|
||||
exec("INSERT INTO smoking_wallets (user_id) VALUES (?) ON CONFLICT DO NOTHING", user)
|
||||
insert("INSERT INTO smoking_entries (user_id,kind,quantity,delta,balance,request_key,payload_hash) VALUES (?,?,?,?,?,?,?)", user, "purchase", 20, 20, 20, "test", strings.Repeat("a", 64))
|
||||
if _, e := tx.Query("SELECT TO_CHAR(created_at,'YYYY-MM-DD') AS day,COUNT(*) AS count FROM smoking_entries WHERE user_id=? GROUP BY TO_CHAR(created_at,'YYYY-MM-DD')", user); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
exec("INSERT INTO period_daily_records (couple_id,user_id,record_date,period) VALUES (?,?,?,'1') ON CONFLICT (record_date,couple_id) WHERE deleted_at IS NULL DO UPDATE SET period=EXCLUDED.period", couple, user, "2026-11-01")
|
||||
exec("INSERT INTO period_daily_records (couple_id,user_id,record_date,period) VALUES (?,?,?,'1') ON CONFLICT (record_date,couple_id) WHERE deleted_at IS NULL DO UPDATE SET period=EXCLUDED.period", couple, user, "2026-11-01")
|
||||
exec("DELETE FROM photos WHERE id=?", photo)
|
||||
row, e = tx.QueryOne("SELECT COUNT(*) AS count FROM photos WHERE id=?", photo)
|
||||
if e != nil || row.Int("count") != 0 {
|
||||
t.Fatalf("soft-delete filter: %v %v", row, e)
|
||||
}
|
||||
var retained model.Photo
|
||||
if e := tx.ORM.Unscoped().First(&retained, "id=?", photo).Error; e != nil || !retained.DeletedAt.Valid {
|
||||
t.Fatalf("physical row not retained: %+v %v", retained, e)
|
||||
}
|
||||
exec("UPDATE photos SET description=? WHERE id=?", "should not change", photo)
|
||||
if e := tx.ORM.Unscoped().First(&retained, "id=?", photo).Error; e != nil || retained.Description == "should not change" {
|
||||
t.Fatal("updated a deleted record", e)
|
||||
}
|
||||
// Check that a deleted right-hand join target stays absent without hiding its parent.
|
||||
exec("DELETE FROM users WHERE id=?", second)
|
||||
row, e = tx.QueryOne("SELECT c.id,u.nickname FROM couples c LEFT JOIN users u ON u.id=c.user2_id WHERE c.id=?", couple)
|
||||
if e != nil || row == nil || !row.IsNull("nickname") {
|
||||
t.Fatalf("LEFT JOIN deleted row: %v %v", row, e)
|
||||
}
|
||||
// All registered physical tables have the complete UUID BaseModel schema.
|
||||
for _, m := range model.Models() {
|
||||
st := &gorm.Statement{DB: tx.ORM}
|
||||
if e := st.Parse(m); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var col struct {
|
||||
DataType string
|
||||
ColumnDefault *string
|
||||
}
|
||||
if e := tx.ORM.Raw("SELECT data_type,column_default FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=? AND column_name='id'", st.Schema.Table).Scan(&col).Error; e != nil || col.DataType != "uuid" || col.ColumnDefault == nil || !strings.Contains(*col.ColumnDefault, "gen_random_uuid") {
|
||||
t.Fatalf("%s invalid UUID identity: %+v %v", st.Schema.Table, col, e)
|
||||
}
|
||||
for _, field := range []string{"created_at", "updated_at", "deleted_at"} {
|
||||
if !tx.ORM.Migrator().HasColumn(m, field) {
|
||||
t.Fatalf("%s missing %s", st.Schema.Table, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the complete static read-query surface against PostgreSQL's planner.
|
||||
// EXPLAIN (without ANALYZE) does not execute SELECT functions or modify data.
|
||||
func TestPostgresReportQueries(t *testing.T) {
|
||||
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set PAST_TEST_POSTGRES_DSN")
|
||||
}
|
||||
db, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
checked := 0
|
||||
for _, dir := range []string{"../handler", "../httpapi", "../wechat", "../notify", "."} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || (sel.Sel.Name != "Query" && sel.Sel.Name != "QueryOne") {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
q, err := strconv.Unquote(lit.Value)
|
||||
if err != nil || !strings.HasPrefix(strings.TrimSpace(q), "SELECT") {
|
||||
return true
|
||||
}
|
||||
scoped, err := scopeQuery(q)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", fset.Position(call.Pos()), err)
|
||||
return true
|
||||
}
|
||||
ts, _ := tokens(q)
|
||||
var args []any
|
||||
for _, tok := range ts {
|
||||
if tok.text == "?" {
|
||||
args = append(args, nil)
|
||||
}
|
||||
}
|
||||
rows, err := db.ORM.Raw("EXPLAIN "+scoped, args...).Rows()
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v\n%s", fset.Position(call.Pos()), err, q)
|
||||
} else {
|
||||
rows.Close()
|
||||
checked++
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Logf("PostgreSQL planned %d static report queries", checked)
|
||||
}
|
||||
|
||||
func TestPostgresWriteStatements(t *testing.T) {
|
||||
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set PAST_TEST_POSTGRES_DSN")
|
||||
}
|
||||
db, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var sql string
|
||||
capture := func(tx *gorm.DB) { sql = tx.Dialector.Explain(tx.Statement.SQL.String(), tx.Statement.Vars...) }
|
||||
db.ORM.Callback().Create().After("gorm:create").Register("test:capture", capture)
|
||||
db.ORM.Callback().Update().After("gorm:update").Register("test:capture", capture)
|
||||
db.ORM.Callback().Delete().After("gorm:delete").Register("test:capture", capture)
|
||||
dry := db.ORM.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true})
|
||||
checked := 0
|
||||
for _, dir := range []string{"../handler", "../httpapi", "../wechat", "."} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || (sel.Sel.Name != "Exec" && sel.Sel.Name != "Insert") {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
q, err := strconv.Unquote(lit.Value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(q, "INSERT ") && !strings.HasPrefix(q, "UPDATE ") && !strings.HasPrefix(q, "DELETE ") {
|
||||
return true
|
||||
}
|
||||
ts, _ := tokens(q)
|
||||
var args []any
|
||||
for _, tok := range ts {
|
||||
if tok.text == "?" {
|
||||
args = append(args, nil)
|
||||
}
|
||||
}
|
||||
sql = ""
|
||||
if _, err := execute(dry, q, args...); err != nil {
|
||||
t.Errorf("%s: %v", fset.Position(call.Pos()), err)
|
||||
return true
|
||||
}
|
||||
if sql == "" {
|
||||
t.Errorf("%s: no GORM statement generated", fset.Position(call.Pos()))
|
||||
return true
|
||||
}
|
||||
rows, err := db.ORM.Raw("EXPLAIN " + sql).Rows()
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v\n%s", fset.Position(call.Pos()), err, sql)
|
||||
} else {
|
||||
rows.Close()
|
||||
checked++
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Logf("GORM/PostgreSQL planned %d static write statements without executing them", checked)
|
||||
}
|
||||
@@ -16,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_") {
|
||||
@@ -64,9 +61,7 @@ func (db *DB) RunUnbindingWorker() {
|
||||
if err := db.AdvanceUnbindings(); err != nil {
|
||||
slog.Error("解绑到期处理失败", "err", err)
|
||||
}
|
||||
if err := db.cleanUnboundFiles(); err != nil {
|
||||
slog.Error("解绑附件清理失败,将重试", "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,407 @@
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,185 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"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 err
|
||||
}
|
||||
defer conn.Close()
|
||||
d := &connRunner{conn}
|
||||
if _, err := d.Exec("SET NAMES utf8mb4"); err != nil {
|
||||
return err
|
||||
}
|
||||
// 与 PHP 一致:建表阶段关闭外键检查(表之间存在前向引用),结束后恢复
|
||||
if _, err := d.Exec("SET FOREIGN_KEY_CHECKS = 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Exec("SET FOREIGN_KEY_CHECKS = 1")
|
||||
|
||||
for _, stmt := range createTables {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
name := strings.Fields(stmt)[5]
|
||||
return fmt.Errorf("建表 %s 失败: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认管理员(密码 admin123)
|
||||
cnt, err := d.QueryOne("SELECT COUNT(*) as cnt FROM admins")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt.Int("cnt") == 0 {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
// 与 PHP password_hash 一致使用 $2y$ 前缀(算法相同)
|
||||
phpHash := "$2y$" + strings.TrimPrefix(string(hash), "$2a$")
|
||||
if _, err := d.Exec("INSERT INTO admins (username, password) VALUES (?, ?)", "admin", phpHash); err != nil {
|
||||
return db.ORM.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(520001)").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 补列(含 users.phone 的特殊迁移逻辑)
|
||||
for _, ac := range addColumns {
|
||||
exists, err := d.columnExists(ac.Table, ac.Column)
|
||||
if 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
|
||||
}
|
||||
current := st.Schema.Table
|
||||
previous := "past-" + strings.TrimPrefix(current, "past_")
|
||||
if !tx.Migrator().HasTable(previous) {
|
||||
continue
|
||||
}
|
||||
if tx.Migrator().HasTable(current) {
|
||||
return fmt.Errorf("同时存在 %s 与 %s,停止迁移以避免覆盖数据", previous, current)
|
||||
}
|
||||
if err := tx.Migrator().RenameTable(previous, current); err != nil {
|
||||
return err
|
||||
}
|
||||
// The default deleted_at index derives its name from the table name.
|
||||
oldIndex := NamingStrategy.IndexName(previous, "deleted_at")
|
||||
newIndex := NamingStrategy.IndexName(current, "deleted_at")
|
||||
if tx.Migrator().HasIndex(current, oldIndex) && !tx.Migrator().HasIndex(current, newIndex) {
|
||||
if err := tx.Migrator().RenameIndex(current, oldIndex, newIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.AutoMigrate(model.Models()...); err != nil {
|
||||
return fmt.Errorf("GORM 自动迁移失败: %w", err)
|
||||
}
|
||||
// NULL couple_id denotes personal records. Partial indexes allow recreating
|
||||
// a deleted record while preserving history and enforce personal date uniqueness.
|
||||
for _, stmt := range []string{
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_period_personal_date ON "past_period_daily_record" (user_id,record_date) WHERE deleted_at IS NULL AND couple_id IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_unbind_deadline ON "past_unbind_request" (status,cooling_until) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_smoking_recent ON "past_smoking_entry" (user_id,created_at) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_activity_recent ON "past_activity_log" (couple_id,created_at DESC) WHERE deleted_at IS NULL`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.Admin{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
if count == 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admin := model.Admin{Username: "admin", Password: "$2y$" + strings.TrimPrefix(string(hash), "$2a$")}
|
||||
if err := tx.Create(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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)
|
||||
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 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 {
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 补全历史数据: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
|
||||
}
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS smoking_wallets (user_id INT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS smoking_entries (id INT AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,kind VARCHAR(20) NOT NULL,quantity INT NOT NULL,delta INT NOT NULL,balance INT NOT NULL,amount_cents INT NOT NULL DEFAULT 0,note VARCHAR(200) NOT NULL DEFAULT '',request_key VARCHAR(64) NOT NULL,payload_hash CHAR(64) NOT NULL,original_id INT DEFAULT NULL,undone TINYINT NOT NULL DEFAULT 0,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY smoking_personal_request(user_id,request_key),KEY smoking_personal_recent(user_id,created_at),FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
} {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return 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
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
package store
|
||||
|
||||
// 本文件由 backend/install.php 的 SQL 语句自动提取生成,表结构与线上 PHP 版本保持一致。
|
||||
// 请勿手工改动字段定义;如需新增字段,追加到 addColumns 中。
|
||||
|
||||
// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。
|
||||
var createTables = []string{
|
||||
`CREATE TABLE IF NOT EXISTS unbinding_file_cleanup (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(20) UNIQUE,
|
||||
openid VARCHAR(100) UNIQUE,
|
||||
phone VARCHAR(50) DEFAULT '',
|
||||
username VARCHAR(100) DEFAULT '',
|
||||
password VARCHAR(255) DEFAULT '',
|
||||
nickname VARCHAR(100) DEFAULT '',
|
||||
avatar VARCHAR(500) DEFAULT '',
|
||||
gender VARCHAR(10) DEFAULT '',
|
||||
couple_id INT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS couples (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_space_id VARCHAR(50) UNIQUE,
|
||||
user1_id INT NOT NULL,
|
||||
user2_id INT NOT NULL,
|
||||
love_start_date VARCHAR(20) DEFAULT '',
|
||||
love_story TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
data_deleted TINYINT DEFAULT 0,
|
||||
period_visible TINYINT DEFAULT 0 COMMENT '男方是否可见女生日历 0-不可见 1-可见',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS invitations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
from_user_id INT NOT NULL,
|
||||
to_user_id INT DEFAULT NULL,
|
||||
to_phone VARCHAR(50) DEFAULT '',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
message TEXT,
|
||||
invite_code VARCHAR(100) DEFAULT '',
|
||||
type VARCHAR(20) DEFAULT 'phone',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
responded_at DATETIME,
|
||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS unbind_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
requester_id INT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
responded_at DATETIME DEFAULT NULL,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS invite_links (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
code VARCHAR(100) NOT NULL UNIQUE,
|
||||
used INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
event_date VARCHAR(20) NOT NULL,
|
||||
location VARCHAR(200) DEFAULT '',
|
||||
images TEXT,
|
||||
icon VARCHAR(20) DEFAULT '❤️',
|
||||
sort_order INT DEFAULT 0,
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS albums (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cover VARCHAR(500) DEFAULT '',
|
||||
sort_order INT DEFAULT 0,
|
||||
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
|
||||
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
|
||||
location VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称',
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS photos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
album_id INT NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
taken_at VARCHAR(50) DEFAULT '',
|
||||
location VARCHAR(200) DEFAULT '',
|
||||
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
|
||||
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
|
||||
activity VARCHAR(200) DEFAULT '',
|
||||
batch_id VARCHAR(50) DEFAULT '',
|
||||
sort_order INT DEFAULT 0,
|
||||
created_by INT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS diaries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
mood VARCHAR(20) DEFAULT '😊',
|
||||
weather VARCHAR(20) DEFAULT '☀️',
|
||||
diary_date VARCHAR(20) NOT NULL,
|
||||
images TEXT,
|
||||
is_anniversary INT DEFAULT 0,
|
||||
region VARCHAR(100) DEFAULT '' COMMENT '发布地区',
|
||||
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
|
||||
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
|
||||
location VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称',
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS songs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
song_name VARCHAR(255) NOT NULL,
|
||||
artist VARCHAR(255) NOT NULL,
|
||||
note VARCHAR(120) DEFAULT '',
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS checkins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
icon VARCHAR(20) DEFAULT '✅',
|
||||
type VARCHAR(10) DEFAULT 'dual',
|
||||
frequency VARCHAR(10) DEFAULT 'daily',
|
||||
is_completed INT DEFAULT 0,
|
||||
completed_by_me INT DEFAULT 0,
|
||||
completed_by_partner INT DEFAULT 0,
|
||||
my_checkin_time DATETIME,
|
||||
partner_checkin_time DATETIME,
|
||||
my_streak INT DEFAULT 0,
|
||||
partner_streak INT DEFAULT 0,
|
||||
my_photo VARCHAR(500) DEFAULT '',
|
||||
partner_photo VARCHAR(500) DEFAULT '',
|
||||
my_note VARCHAR(200) DEFAULT '',
|
||||
partner_note VARCHAR(200) DEFAULT '',
|
||||
completed_at DATETIME,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS checkin_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
checkin_id INT NOT NULL,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
photo VARCHAR(500) DEFAULT '',
|
||||
note VARCHAR(200) DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS smoking_accounts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
stock INT NOT NULL DEFAULT 0,
|
||||
checkin_id INT DEFAULT NULL,
|
||||
UNIQUE KEY smoking_owner (couple_id, user_id),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS smoking_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
account_id INT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
quantity INT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance INT NOT NULL,
|
||||
amount_cents INT NOT NULL DEFAULT 0,
|
||||
note VARCHAR(200) NOT NULL DEFAULT '',
|
||||
request_key VARCHAR(64) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
checkin_log_id INT DEFAULT NULL,
|
||||
original_id INT DEFAULT NULL,
|
||||
undone TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY smoking_request (account_id, request_key),
|
||||
KEY smoking_recent (account_id, created_at),
|
||||
FOREIGN KEY (account_id) REFERENCES smoking_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS period_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NULL,
|
||||
user_id INT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
cycle_length INT DEFAULT 28,
|
||||
period_length INT DEFAULT 5,
|
||||
symptoms VARCHAR(500) DEFAULT '',
|
||||
note TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS period_daily_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NULL,
|
||||
user_id INT NOT NULL,
|
||||
record_date DATE NOT NULL,
|
||||
period ENUM('0','1','2','3') DEFAULT '0' COMMENT '0-无 1-少量 2-中量 3-大量',
|
||||
sex ENUM('0','1') DEFAULT '0' COMMENT '0-无 1-爱爱',
|
||||
symptoms VARCHAR(200) DEFAULT '' COMMENT '症状,逗号分隔',
|
||||
mood VARCHAR(100) DEFAULT '' COMMENT '心情',
|
||||
discharge VARCHAR(100) DEFAULT '' COMMENT '白带',
|
||||
temperature DECIMAL(4,1) DEFAULT NULL COMMENT '体温',
|
||||
weight DECIMAL(5,1) DEFAULT NULL COMMENT '体重',
|
||||
note TEXT COMMENT '备注',
|
||||
period_detail TEXT COMMENT '经期详情JSON(量/颜色/疼痛)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_couple (record_date, couple_id),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS period_sex_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NULL,
|
||||
user_id INT NOT NULL,
|
||||
record_date DATE NOT NULL,
|
||||
record_time TIME DEFAULT NULL COMMENT '记录时间,精确到分钟',
|
||||
measure VARCHAR(100) DEFAULT '' COMMENT '措施类型',
|
||||
measure_custom VARCHAR(200) DEFAULT '' COMMENT '其他措施自定义内容',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_date_couple (record_date, couple_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
` + "`" + `key` + "`" + ` VARCHAR(255) PRIMARY KEY,
|
||||
` + "`" + `value` + "`" + ` TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS diary_comments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
diary_id INT NOT NULL,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
parent_id INT DEFAULT 0 COMMENT '回复的评论ID,0为顶级评论',
|
||||
reply_to_user_id INT DEFAULT NULL COMMENT '被回复的用户ID',
|
||||
reply_to_username VARCHAR(100) DEFAULT '' COMMENT '被回复的用户昵称',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (diary_id) REFERENCES diaries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_diary (diary_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS subscribe_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
template_id VARCHAR(100) NOT NULL,
|
||||
template_type VARCHAR(50) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_template (user_id, template_type),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(500) NOT NULL,
|
||||
detail TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_couple_time (couple_id, created_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS admins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(128) NOT NULL UNIQUE,
|
||||
user_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS subscribe_message_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
activity_id INT NOT NULL DEFAULT 0,
|
||||
couple_id INT DEFAULT NULL,
|
||||
user_id INT NOT NULL DEFAULT 0,
|
||||
to_user_id INT NOT NULL,
|
||||
to_openid VARCHAR(128) NOT NULL DEFAULT '',
|
||||
action_type VARCHAR(50) NOT NULL DEFAULT '',
|
||||
message_title VARCHAR(255) NOT NULL DEFAULT '',
|
||||
message_content TEXT,
|
||||
template_type VARCHAR(50) NOT NULL,
|
||||
template_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
page VARCHAR(255) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_code INT DEFAULT NULL,
|
||||
http_status INT DEFAULT NULL,
|
||||
error_msg TEXT,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
duration_ms INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME DEFAULT NULL,
|
||||
INDEX idx_status_time (status, created_at),
|
||||
INDEX idx_recipient_time (to_user_id, created_at),
|
||||
INDEX idx_openid (to_openid),
|
||||
INDEX idx_event_time (action_type, created_at),
|
||||
INDEX idx_activity (activity_id),
|
||||
INDEX idx_couple (couple_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS notify_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
activity_id INT NOT NULL,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
template_type VARCHAR(50) NOT NULL,
|
||||
to_user_id INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT '',
|
||||
error_msg TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME DEFAULT NULL,
|
||||
UNIQUE KEY uk_activity (activity_id, to_user_id),
|
||||
INDEX idx_status_time (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL UNIQUE,
|
||||
name VARCHAR(50) NOT NULL DEFAULT '小狗狗',
|
||||
breed VARCHAR(20) NOT NULL DEFAULT 'golden' COMMENT 'golden/husky/corgi/shiba',
|
||||
gender TINYINT NOT NULL DEFAULT 1 COMMENT '1-男 2-女',
|
||||
stage INT NOT NULL DEFAULT 1 COMMENT '1幼犬 2少年 3青年 4成年 5终极',
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
exp INT NOT NULL DEFAULT 0,
|
||||
happiness INT NOT NULL DEFAULT 100,
|
||||
hunger INT NOT NULL DEFAULT 100,
|
||||
energy INT NOT NULL DEFAULT 100,
|
||||
cleanliness INT NOT NULL DEFAULT 100,
|
||||
adopted_at DATE NOT NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pet_dailies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
feed_u1 TINYINT DEFAULT 0,
|
||||
feed_u2 TINYINT DEFAULT 0,
|
||||
water_u1 TINYINT DEFAULT 0,
|
||||
water_u2 TINYINT DEFAULT 0,
|
||||
walk_u1 TINYINT DEFAULT 0,
|
||||
walk_u2 TINYINT DEFAULT 0,
|
||||
bath_u1 TINYINT DEFAULT 0,
|
||||
bath_u2 TINYINT DEFAULT 0,
|
||||
sleep_u1 TINYINT DEFAULT 0,
|
||||
sleep_u2 TINYINT DEFAULT 0,
|
||||
play_u1 TINYINT DEFAULT 0,
|
||||
play_u2 TINYINT DEFAULT 0,
|
||||
all_done TINYINT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_couple_date (couple_id, date),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pet_achievements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
achievement_key VARCHAR(50) NOT NULL,
|
||||
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_couple_ach (couple_id, achievement_key),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pet_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
item_key VARCHAR(50) NOT NULL,
|
||||
item_type VARCHAR(20) NOT NULL COMMENT 'accessory/collar/scarf/background/consumable',
|
||||
quantity INT DEFAULT 1,
|
||||
equipped TINYINT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_couple (couple_id),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pet_dynamics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
couple_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(30) NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_couple_time (couple_id, created_at DESC),
|
||||
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS storage_orders (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
couple_id INT DEFAULT NULL,
|
||||
out_trade_no VARCHAR(64) NOT NULL UNIQUE,
|
||||
plan_key VARCHAR(20) NOT NULL COMMENT 'month/year',
|
||||
product_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
amount_cents INT NOT NULL DEFAULT 0,
|
||||
env TINYINT NOT NULL DEFAULT 1 COMMENT '0-正式 1-沙箱',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/success/closed',
|
||||
wx_order_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attach TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
paid_at DATETIME DEFAULT NULL,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status, created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。
|
||||
var addColumns = []addColumn{
|
||||
{"unbind_requests", "cancelled_at", "DATETIME DEFAULT NULL"},
|
||||
{"unbind_requests", "cancelled_by", "INT DEFAULT NULL"},
|
||||
{"unbind_requests", "cooling_until", "DATETIME DEFAULT NULL"},
|
||||
{"unbind_requests", "completed_at", "DATETIME DEFAULT NULL"},
|
||||
{"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"},
|
||||
{"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"},
|
||||
{"checkins", "completed_by_me", "INT DEFAULT 0"},
|
||||
{"checkins", "completed_by_partner", "INT DEFAULT 0"},
|
||||
{"checkins", "my_checkin_time", "DATETIME"},
|
||||
{"checkins", "partner_checkin_time", "DATETIME"},
|
||||
{"checkins", "my_streak", "INT DEFAULT 0"},
|
||||
{"checkins", "partner_streak", "INT DEFAULT 0"},
|
||||
{"checkins", "my_photo", "VARCHAR(500) DEFAULT ''"},
|
||||
{"checkins", "partner_photo", "VARCHAR(500) DEFAULT ''"},
|
||||
{"checkins", "my_note", "VARCHAR(200) DEFAULT ''"},
|
||||
{"checkins", "partner_note", "VARCHAR(200) DEFAULT ''"},
|
||||
{"period_daily_records", "period_detail", "TEXT COMMENT '经期详情JSON(量/颜色/疼痛)'"},
|
||||
{"users", "openid", "VARCHAR(100) UNIQUE"},
|
||||
{"users", "phone", "VARCHAR(50) DEFAULT ''"},
|
||||
{"users", "gender", "VARCHAR(10) DEFAULT ''"},
|
||||
{"users", "period_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '姨姨:是否对伴侣可见'"},
|
||||
{"users", "age", "INT DEFAULT 0"},
|
||||
{"users", "birthday", "VARCHAR(20) DEFAULT ''"},
|
||||
{"users", "last_login_at", "DATETIME DEFAULT NULL"},
|
||||
{"users", "region", "VARCHAR(100) DEFAULT '' COMMENT '地区,如云南楚雄'"},
|
||||
{"users", "location", "VARCHAR(200) DEFAULT '' COMMENT '模糊定位,如纬度,经度'"},
|
||||
{"users", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度'"},
|
||||
{"users", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度'"},
|
||||
{"users", "last_login_location", "VARCHAR(200) DEFAULT '' COMMENT '最近一次登录地址'"},
|
||||
{"users", "ip_geo_date", "DATE DEFAULT NULL COMMENT '最后一次 IP 定位日期(每日限流)'"},
|
||||
{"users", "last_ip", "VARCHAR(64) DEFAULT '' COMMENT '最后一次 IP 定位的客户端 IP 地址'"},
|
||||
{"invitations", "to_phone", "VARCHAR(50) DEFAULT ''"},
|
||||
{"invitations", "invite_code", "VARCHAR(100) DEFAULT ''"},
|
||||
{"invitations", "type", "VARCHAR(20) DEFAULT 'phone'"},
|
||||
{"couples", "couple_space_id", "VARCHAR(50) UNIQUE"},
|
||||
{"couples", "period_source_user_id", "INT DEFAULT NULL COMMENT '绑定前姨姨记录选定的来源用户'"},
|
||||
{"couples", "data_deleted", "TINYINT DEFAULT 0"},
|
||||
{"photos", "taken_at", "VARCHAR(50) DEFAULT ''"},
|
||||
{"photos", "location", "VARCHAR(200) DEFAULT ''"},
|
||||
{"photos", "batch_id", "VARCHAR(50) DEFAULT ''"},
|
||||
{"photos", "activity", "VARCHAR(200) DEFAULT ''"},
|
||||
{"photos", "created_by", "INT DEFAULT NULL"},
|
||||
{"diaries", "region", "VARCHAR(100) DEFAULT '' COMMENT '发布地区' AFTER is_anniversary"},
|
||||
{"diaries", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER region"},
|
||||
{"diaries", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
|
||||
{"diaries", "location", "VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称' AFTER lng"},
|
||||
{"albums", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER sort_order"},
|
||||
{"albums", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
|
||||
{"albums", "location", "VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称' AFTER lng"},
|
||||
{"photos", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER location"},
|
||||
{"photos", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
|
||||
{"timeline", "location", "VARCHAR(200) DEFAULT '' AFTER event_date"},
|
||||
{"timeline", "repeat_interval", "INT DEFAULT 0 AFTER location"},
|
||||
{"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"},
|
||||
{"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"},
|
||||
{"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"},
|
||||
{"users", "session_key", "VARCHAR(128) DEFAULT '' COMMENT '微信会话密钥,用于虚拟支付签名'"},
|
||||
{"users", "storage_vip_expire_at", "DATETIME DEFAULT NULL COMMENT '存储会员到期时间,NULL=未开通'"},
|
||||
{"users", "storage_quota_mb", "INT DEFAULT 0 COMMENT '自定义存储容量MB,0=使用默认免费额度'"},
|
||||
{"users", "storage_plan", "VARCHAR(20) DEFAULT '' COMMENT '当前存储套餐 month/year/空=未开通'"},
|
||||
{"users", "storage_plan_started_at", "DATETIME DEFAULT NULL COMMENT '存储套餐开通时间'"},
|
||||
{"users", "smoking_card_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '抽烟卡片是否显示:1显示 0隐藏'"},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+228
-116
@@ -1,53 +1,113 @@
|
||||
// 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"
|
||||
"gorm.io/gorm"
|
||||
"past/internal/config"
|
||||
"past/internal/model"
|
||||
"past/internal/store"
|
||||
)
|
||||
|
||||
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
|
||||
const (
|
||||
ErrNoFileReceived = 1001
|
||||
ErrFileFieldMissing = 1002
|
||||
ErrHandleFailed = 1003
|
||||
ErrRawBodySave = 1004
|
||||
ErrAlbumNotFound = 1005
|
||||
ErrNotLoggedIn = 1006
|
||||
ErrNotBound = 1007
|
||||
ErrMethodNotAllowed = 1008
|
||||
ErrPartnerInvalid = 1009
|
||||
ErrFileTooLarge = 1010
|
||||
ErrNoFileReceived = 1001
|
||||
ErrFileFieldMissing = 1002
|
||||
ErrHandleFailed = 1003
|
||||
ErrRawBodySave = 1004
|
||||
ErrAlbumNotFound = 1005
|
||||
ErrNotLoggedIn = 1006
|
||||
ErrNotBound = 1007
|
||||
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
|
||||
MaxSize int64
|
||||
Checker ImageChecker
|
||||
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{DB: db, Client: client, Bucket: cfg.Bucket, Endpoint: strings.TrimSuffix(endpoint, "/"), MaxSize: maxSize, Checker: checker}, nil
|
||||
}
|
||||
|
||||
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
|
||||
@@ -62,8 +122,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 +142,165 @@ 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: "创建临时文件失败"}
|
||||
}
|
||||
if _, err := io.Copy(out, src); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dest)
|
||||
return Result{Error: "文件保存失败。系统错误: " + err.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: "读取文件失败"}
|
||||
}
|
||||
out.Close()
|
||||
slog.Info("upload saved", "path", rel+name)
|
||||
|
||||
if u.Checker != nil {
|
||||
if pass, msg := u.Checker.CheckImage(dest, ""); !pass {
|
||||
os.Remove(dest)
|
||||
return Result{Error: msg}
|
||||
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 != "" {
|
||||
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 := 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(tmp.Name(), ""); !pass {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
file.ObjectKey = time.Now().In(loc).Format("2006/01/") + digest + "." + suffix
|
||||
file.Url = u.Endpoint + "/" + u.Bucket + "/" + file.ObjectKey
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := u.Client.PutObject(ctx, u.Bucket, file.ObjectKey, tmp, size, minio.PutObjectOptions{ContentType: contentType, SendContentMd5: true, UserMetadata: map[string]string{"md5": digest, "sha256": sha}}); err != nil {
|
||||
return fmt.Errorf("上传 MinIO 失败: %w", err)
|
||||
}
|
||||
if err := tx.Create(&file).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return store.GrantFile(tx, userId, file.Id)
|
||||
})
|
||||
if err != nil {
|
||||
code := 0
|
||||
if errors.Is(err, store.ErrUploadQuota) {
|
||||
code = ErrStorageQuotaExceeded
|
||||
}
|
||||
return Result{Error: err.Error(), Code: code}
|
||||
}
|
||||
return Result{Path: rel + name}
|
||||
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)))
|
||||
}
|
||||
|
||||
// Abs 返回相对路径对应的绝对路径
|
||||
func (u *Uploader) Abs(relPath string) string {
|
||||
p := strings.TrimPrefix(relPath, "uploads/")
|
||||
return filepath.Join(u.Dir, filepath.FromSlash(p))
|
||||
}
|
||||
|
||||
// Exists 文件是否存在
|
||||
func (u *Uploader) Exists(relPath string) bool {
|
||||
st, err := os.Stat(u.Abs(relPath))
|
||||
return err == nil && !st.IsDir()
|
||||
}
|
||||
// Removing one business reference must never delete a shared physical object.
|
||||
func (u *Uploader) Remove(path string) {}
|
||||
|
||||
@@ -46,6 +46,8 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
||||
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 {
|
||||
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] != "new diary" {
|
||||
t.Errorf("wrong recipient/event: %v", args)
|
||||
return nil
|
||||
@@ -78,7 +80,7 @@ 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})
|
||||
client := New("app", "secret", tt.template, t.TempDir(), store.WrapSQL(db))
|
||||
os.MkdirAll(filepath.Dir(client.cacheFile()), 0755)
|
||||
os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600)
|
||||
calls := 0
|
||||
@@ -94,7 +96,7 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -107,25 +109,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", t.TempDir(), 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", t.TempDir(), 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
@@ -188,10 +189,10 @@ func (c *Client) Jscode2Session(code string) (openid string, sessionKey string,
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
@@ -214,7 +215,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)
|
||||
}
|
||||
}()
|
||||
@@ -336,9 +337,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
|
||||
@@ -346,7 +347,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")
|
||||
}
|
||||
@@ -366,11 +367,11 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templat
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -379,7 +380,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")
|
||||
@@ -407,3 +408,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()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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() {
|
||||
@@ -47,6 +48,7 @@ export default function App() {
|
||||
<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} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"
|
||||
import { useQueryClient, useIsFetching } from "@tanstack/react-query"
|
||||
import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell, HardDrive } from "lucide-react"
|
||||
import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell, HardDrive, FolderOpen } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -13,6 +13,7 @@ const NAV = [
|
||||
{ to: "/users", label: "用户管理", icon: Users, group: "空间管理" },
|
||||
{ to: "/couples", label: "情侣管理", icon: Heart, group: "空间管理" },
|
||||
{ to: "/contents", label: "内容管理", icon: FileText, group: "空间管理" },
|
||||
{ to: "/files", label: "文件管理", icon: FolderOpen, group: "空间管理" },
|
||||
{ to: "/unbind", label: "解绑管理", icon: Unlink, group: "空间管理" },
|
||||
{ to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" },
|
||||
{ to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
|
||||
// ---------- 用户详情(含编辑资料、打卡/日记/相册记录)----------
|
||||
|
||||
export function UserDetailDialog({ userId, onClose, onDelete }: { userId: number | null; onClose: () => void; onDelete: (u: { id: number; name: string; hasPartner: boolean }) => void }) {
|
||||
export function UserDetailDialog({ userId, onClose, onDelete }: { userId: string | null; onClose: () => void; onDelete: (u: { id: string; name: string; hasPartner: boolean }) => void }) {
|
||||
const qc = useQueryClient()
|
||||
const q = useQuery({
|
||||
queryKey: ["admin_user_detail", userId],
|
||||
@@ -182,7 +182,7 @@ function CheckinItem({ c }: { c: any }) {
|
||||
|
||||
// ---------- 重置密码 ----------
|
||||
|
||||
export function ResetPasswordDialog({ user, onClose }: { user: { id: number; name: string } | null; onClose: () => void }) {
|
||||
export function ResetPasswordDialog({ user, onClose }: { user: { id: string; name: string } | null; onClose: () => void }) {
|
||||
const [p1, setP1] = useState("")
|
||||
const [p2, setP2] = useState("")
|
||||
useEffect(() => { setP1(""); setP2("") }, [user])
|
||||
@@ -217,7 +217,7 @@ export function ResetPasswordDialog({ user, onClose }: { user: { id: number; nam
|
||||
|
||||
// ---------- 删除用户确认 ----------
|
||||
|
||||
export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: number; name: string; hasPartner: boolean } | null; onClose: () => void; onDone?: () => void }) {
|
||||
export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: string; name: string; hasPartner: boolean } | null; onClose: () => void; onDone?: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
async function confirm() {
|
||||
if (!user) return
|
||||
@@ -255,7 +255,7 @@ export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: number
|
||||
|
||||
// ---------- 删除内容确认(情侣详情 / 内容管理共用)----------
|
||||
|
||||
export type DeleteContentTarget = { type: string; id: number; coupleId: number; label: string }
|
||||
export type DeleteContentTarget = { type: string; id: string; coupleId: string; label: string }
|
||||
|
||||
export function DeleteContentDialog({ target, onClose, onDone }: { target: DeleteContentTarget | null; onClose: () => void; onDone?: () => void }) {
|
||||
async function confirm() {
|
||||
|
||||
@@ -55,13 +55,7 @@ export async function apiUpload<T = any>(action: string, form: FormData): Promis
|
||||
return fromWire(json) as T
|
||||
}
|
||||
|
||||
// v 参数:使后台的图片缓存键与旧的(曾被缓存的 404)不同;小程序端不受影响
|
||||
const MEDIA_VER = "2"
|
||||
export function mediaUrl(path?: string | null): string {
|
||||
if (!path) return ""
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path
|
||||
return MEDIA_BASE + path.replace(/^\//, "") + "?v=" + MEDIA_VER
|
||||
}
|
||||
export { mediaUrl, type UploadedFileResult } from "./media"
|
||||
|
||||
// "2026-09-17 10:31:52" → "2026-09-17 10:31"
|
||||
export function fmtTime(s?: string | null): string {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const MEDIA_BASE = "/backend/"
|
||||
// v 参数:使后台的图片缓存键与旧的(曾被缓存的 404)不同;小程序端不受影响
|
||||
const MEDIA_VER = "2"
|
||||
export function mediaUrl(path?: string | null): string {
|
||||
if (!path) return ""
|
||||
if (path.startsWith("/api/files/")) return path
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(path)) return `/api/files/${path}`
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path
|
||||
return MEDIA_BASE + path.replace(/^\//, "") + "?v=" + MEDIA_VER
|
||||
}
|
||||
|
||||
export type UploadedFileResult = {
|
||||
success: boolean
|
||||
file_id: string
|
||||
path: string
|
||||
url: string
|
||||
duplicate: boolean
|
||||
file: { id: string; url: string; bucket: string; object_key: string }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { api, apiUpload, mediaUrl } from "@/lib/api"
|
||||
import { api, apiUpload, mediaUrl, type UploadedFileResult } from "@/lib/api"
|
||||
import { useImagePreview } from "@/components/ImagePreview"
|
||||
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -15,7 +15,7 @@ export default function BackgroundsPage() {
|
||||
const list = useQuery({ queryKey: ["admin_couple_bg_list"], queryFn: () => api<any[]>("adminCoupleBgList", null, "GET") })
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [clearId, setClearId] = useState<number | null>(null)
|
||||
const [clearId, setClearId] = useState<string | null>(null)
|
||||
|
||||
async function upload(f?: File) {
|
||||
if (!f) return
|
||||
@@ -23,7 +23,9 @@ export default function BackgroundsPage() {
|
||||
fd.append("bg", f)
|
||||
setUploading(true)
|
||||
try {
|
||||
await apiUpload("adminSetGlobalBg", fd)
|
||||
const uploaded = await apiUpload<UploadedFileResult>("adminSetGlobalBg", fd)
|
||||
qc.setQueryData(["admin_get_global_bg"], { bg_image: uploaded.path, bg_image_file_id: uploaded.file_id })
|
||||
qc.invalidateQueries({ queryKey: ["admin_files"] })
|
||||
toast.success("全局背景设置成功 🌸")
|
||||
qc.invalidateQueries({ queryKey: ["admin_get_global_bg"] })
|
||||
} catch (e) {
|
||||
@@ -49,7 +51,7 @@ export default function BackgroundsPage() {
|
||||
} catch (e) { toast.error((e as Error).message) } finally { setClearId(null) }
|
||||
}
|
||||
|
||||
const bg = global.data?.bg_image
|
||||
const bg = global.data?.bg_image_file_id || global.data?.bg_image
|
||||
return (
|
||||
<div>
|
||||
<PageTitle>背景管理</PageTitle>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
export default function CouplesPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const q = useQuery({ queryKey: ["admin_couples", page], queryFn: () => api<Paged>("adminCouples", { page, page_size: PAGE_SIZE }, "GET") })
|
||||
const [cid, setCid] = useState<number | null>(null)
|
||||
const [cid, setCid] = useState<string | null>(null)
|
||||
const list = q.data?.items || []
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, type FormEvent } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Copy, ExternalLink, RefreshCw, Search } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { api, fmtTime, mediaUrl } from "@/lib/api"
|
||||
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
|
||||
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
|
||||
import { Thumb } from "@/components/ImagePreview"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type UploadedFile = {
|
||||
id: string; size: number; suffix: string; content_type: string; object_key: string; md5: string;
|
||||
url: string; access_url: string; bucket: string; original_name: string; created_at: string;
|
||||
uploaders: { id: string; display_id: string; nickname: string; deleted: boolean }[]
|
||||
}
|
||||
const initialFilters = { keyword: "", suffix: "all", start_date: "", end_date: "" }
|
||||
function formatSize(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB` }
|
||||
|
||||
export default function FilesPage() {
|
||||
const [draft, setDraft] = useState(initialFilters), [filters, setFilters] = useState(initialFilters), [page, setPage] = useState(1)
|
||||
const q = useQuery({ queryKey: ["admin_files", filters, page], queryFn: () => api<Paged<UploadedFile>>("adminFiles", { ...filters, suffix: filters.suffix === "all" ? undefined : filters.suffix, page, page_size: PAGE_SIZE }, "GET") })
|
||||
function search(e: FormEvent) { e.preventDefault(); setPage(1); setFilters({ ...draft }) }
|
||||
async function copy(value: string) { try { await navigator.clipboard.writeText(value); toast.success("已复制") } catch { toast.error("复制失败,请手动复制") } }
|
||||
return <div>
|
||||
<PageTitle count={q.data ? `共 ${q.data.total} 个文件` : undefined}>文件管理</PageTitle>
|
||||
<p className="mb-4 text-sm text-muted-foreground">查看所有用户上传的文件。相同内容仅保存一份,上传用户分别记录;点击缩略图可预览。</p>
|
||||
<Card><CardContent className="space-y-4">
|
||||
<form onSubmit={search} className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1"><Label htmlFor="file-keyword">搜索文件或用户</Label><Input id="file-keyword" className="w-72" placeholder="文件名、MD5、对象路径、昵称或用户 ID" value={draft.keyword} onChange={e => setDraft({ ...draft, keyword: e.target.value })} /></div>
|
||||
<div className="space-y-1"><Label>文件类型</Label><Select value={draft.suffix} onValueChange={suffix => setDraft({ ...draft, suffix })}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部类型</SelectItem>{["jpg", "png", "gif", "webp"].map(type => <SelectItem key={type} value={type}>{type.toUpperCase()}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label htmlFor="file-from">开始日期</Label><Input id="file-from" type="date" value={draft.start_date} onChange={e => setDraft({ ...draft, start_date: e.target.value })} /></div>
|
||||
<div className="space-y-1"><Label htmlFor="file-to">结束日期</Label><Input id="file-to" type="date" value={draft.end_date} onChange={e => setDraft({ ...draft, end_date: e.target.value })} /></div>
|
||||
<Button type="submit"><Search className="size-4" />查询</Button>
|
||||
<Button type="button" variant="outline" onClick={() => { setDraft(initialFilters); setFilters(initialFilters); setPage(1) }}>重置</Button>
|
||||
<Button type="button" variant="outline" disabled={q.isFetching} onClick={() => q.refetch()}><RefreshCw className="size-4" />刷新</Button>
|
||||
</form>
|
||||
{q.isPending ? <Loading rows={5} /> : q.isError ? <ErrorBox error={q.error} /> : !q.data?.items.length ? <Empty text="暂无符合条件的文件" /> : <>
|
||||
<div className="overflow-x-auto"><Table>
|
||||
<TableHeader><TableRow><TableHead>预览</TableHead><TableHead>文件</TableHead><TableHead>上传用户</TableHead><TableHead>大小 / 类型</TableHead><TableHead>存储位置</TableHead><TableHead>首次上传时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{q.data.items.map(file => <TableRow key={file.id}>
|
||||
<TableCell><Thumb path={mediaUrl(file.id)} title={file.original_name} className="size-16" /></TableCell>
|
||||
<TableCell className="min-w-60 max-w-80"><div className="break-all font-medium">{file.original_name || `${file.id}.${file.suffix}`}</div><div className="mt-1 break-all font-mono text-xs text-muted-foreground select-text">ID:{file.id}</div><details className="mt-1 text-xs"><summary className="cursor-pointer text-primary">MD5 与访问地址</summary><div className="mt-2 space-y-2 break-all select-text"><div>MD5:{file.md5}</div><div>访问地址:{new URL(mediaUrl(file.id), window.location.origin).href}</div><div>对象地址:{file.url}</div><Button variant="outline" size="sm" onClick={() => copy(file.md5)}><Copy className="size-3" />复制 MD5</Button></div></details></TableCell>
|
||||
<TableCell className="min-w-40">{file.uploaders.length ? <div className="space-y-2">{file.uploaders.map(user => <div key={user.id}><span>{user.nickname || "未设置昵称"}</span>{user.deleted && <Badge variant="outline" className="ml-2">已注销</Badge>}<div className="text-xs text-muted-foreground select-text">ID {user.display_id || user.id}</div></div>)}</div> : <span className="text-muted-foreground">管理员上传</span>}</TableCell>
|
||||
<TableCell className="whitespace-nowrap"><div>{formatSize(file.size)}</div><Badge variant="outline" className="mt-1">{file.suffix.toUpperCase()}</Badge><div className="mt-1 text-xs text-muted-foreground">{file.content_type}</div></TableCell>
|
||||
<TableCell className="min-w-48 max-w-64"><div className="text-xs text-muted-foreground">Bucket:{file.bucket}</div><div className="mt-1 break-all font-mono text-xs select-text">{file.object_key}</div></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">{fmtTime(file.created_at)}</TableCell>
|
||||
<TableCell><div className="flex flex-col items-start gap-2"><Button size="sm" variant="outline" asChild><a href={mediaUrl(file.id)} target="_blank" rel="noreferrer"><ExternalLink className="size-3" />打开原图</a></Button><Button size="sm" variant="ghost" onClick={() => copy(new URL(mediaUrl(file.id), window.location.origin).href)}><Copy className="size-3" />复制链接</Button></div></TableCell>
|
||||
</TableRow>)}</TableBody>
|
||||
</Table></div>
|
||||
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
|
||||
</>}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
export default function PetsPage() {
|
||||
const [breed, setBreed] = useState("all")
|
||||
const [stage, setStage] = useState("all")
|
||||
const [cid, setCid] = useState<number | null>(null)
|
||||
const [cid, setCid] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const q = useQuery({
|
||||
queryKey: ["admin_pets", breed, stage, page],
|
||||
|
||||
@@ -25,13 +25,13 @@ function formatBytes(n?: number): string {
|
||||
}
|
||||
|
||||
interface StorageUser {
|
||||
id: number
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
gender: string
|
||||
couple_id: number
|
||||
couple_id: string
|
||||
created_at: string
|
||||
last_login_at: string
|
||||
used_bytes: number
|
||||
@@ -153,8 +153,8 @@ interface StorageSettingsResp {
|
||||
}
|
||||
|
||||
interface StorageOrder {
|
||||
id: number
|
||||
uid: number
|
||||
id: string
|
||||
uid: string
|
||||
display_id: string
|
||||
nickname: string
|
||||
username: string
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function SubscribeMessagesPage(){
|
||||
<TableBody>{q.data.items.map(r=><TableRow key={r.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs">{fmtTime(r.created_at)}<div className="text-muted-foreground">#{r.id}</div></TableCell>
|
||||
<TableCell><div>{r.recipient_name||`用户#${r.to_user_id}`}{r.recipient_uid&&<span className="ml-2 text-xs text-muted-foreground">ID {r.recipient_uid}</span>}</div><div className="mt-1 max-w-64 break-all font-mono text-xs text-muted-foreground select-text">{r.to_openid||"无 OpenID"}</div></TableCell>
|
||||
<TableCell className="whitespace-nowrap">{r.sender_name|| (Number(r.user_id)?`用户#${r.user_id}`:"系统")}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{r.sender_name|| (r.user_id?`用户#${r.user_id}`:"系统")}</TableCell>
|
||||
<TableCell className="min-w-56 max-w-80"><Badge variant="outline">{EVENTS[r.action_type]||r.action_type}</Badge><div className="mt-1">{r.message_title}</div><div className="text-xs text-muted-foreground break-words">{r.message_content}</div></TableCell>
|
||||
<TableCell className="whitespace-nowrap"><Badge variant={r.status==="success"?"secondary":"destructive"}>{r.status==="success"?"发送成功":"发送失败"}</Badge>{r.sent_at&&<div className="mt-1 text-xs text-muted-foreground">{fmtTime(r.sent_at)}</div>}</TableCell>
|
||||
<TableCell className="min-w-48 max-w-80 break-words text-xs">{r.error_msg||"—"}{r.error_code!=null&&<div className="mt-1 text-muted-foreground">微信错误码:{r.error_code}</div>}{r.http_status!=null&&<div className="text-muted-foreground">HTTP:{r.http_status}</div>}</TableCell>
|
||||
|
||||
@@ -29,9 +29,9 @@ export default function UsersPage() {
|
||||
const [bind, setBind] = useState("all")
|
||||
const [sort, setSort] = useState("newest")
|
||||
const [page, setPage] = useState(1)
|
||||
const [detailId, setDetailId] = useState<number | null>(null)
|
||||
const [resetUser, setResetUser] = useState<{ id: number; name: string } | null>(null)
|
||||
const [delUser, setDelUser] = useState<{ id: number; name: string; hasPartner: boolean } | null>(null)
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [resetUser, setResetUser] = useState<{ id: string; name: string } | null>(null)
|
||||
const [delUser, setDelUser] = useState<{ id: string; name: string; hasPartner: boolean } | null>(null)
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["admin_users", search, bind, sort, page],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mediaUrl } from '../src/lib/media.ts'
|
||||
|
||||
test('file UUID uses the stable API endpoint for thumbnails, original and copied links', () => {
|
||||
const id = '6083e119-6b26-441b-8b70-44d224366238'
|
||||
const access = '/api/files/' + id
|
||||
assert.equal(mediaUrl(id), access)
|
||||
assert.equal(mediaUrl(access), access)
|
||||
assert.equal(new URL(mediaUrl(id), 'https://past.sundynix.cn/backend/admin/files').href, 'https://past.sundynix.cn' + access)
|
||||
assert.equal(mediaUrl('------------------------------------'), '/backend/------------------------------------?v=2')
|
||||
assert.equal(mediaUrl(null), '')
|
||||
assert.equal(mediaUrl('https://past.sundynix.cn/api/files/' + id), 'https://past.sundynix.cn/api/files/' + id)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ const cache = require('../../utils/data-cache');
|
||||
const titles = {consume:'抽一支 · 打卡',purchase:'买烟',receive:'别人递烟',receive_smoke:'收到并抽一支',adjust:'调整库存',give:'递给别人一支',undo:'撤销记录'};
|
||||
Component({
|
||||
properties: { detailed: Boolean },
|
||||
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], logsPage:1, logsTotal:0, logsHasMore:false, logsLoadingMore:false, logsMoreError:false, ownerName:'', ownerAvatar:'', ownerInitial:'我' },
|
||||
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:"", lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], logsPage:1, logsTotal:0, logsHasMore:false, logsLoadingMore:false, logsMoreError:false, ownerName:'', ownerAvatar:'', ownerInitial:'我' },
|
||||
lifetimes: { attached(){this.load();} },
|
||||
pageLifetimes: { show(){this.load();} },
|
||||
methods: {
|
||||
@@ -30,8 +30,8 @@ Component({
|
||||
money:info[period.key+'_purchase_cents'] == null ? '—' : (Number(info[period.key+'_purchase_cents'])/100).toFixed(2)
|
||||
}));
|
||||
const profile=this._profile||{};
|
||||
const ownerId=Number(info.user_id || this.data.viewUserId || wx.getStorageSync('userId'));
|
||||
const owner=[profile.partner1,profile.partner2].find(user=>user && Number(user.id)===ownerId)||{};
|
||||
const ownerId=String(info.user_id || this.data.viewUserId || wx.getStorageSync('userId') || '');
|
||||
const owner=[profile.partner1,profile.partner2].find(user=>user && String(user.id)===ownerId)||{};
|
||||
const ownerName=owner.nickname||info.owner_name||owner.username||(info.read_only?'TA':'我');
|
||||
const ownerAvatar=getImageUrl(owner.avatar||info.owner_avatar||'');
|
||||
this.setData({info,logs,daily,periodStats,logsPage:Number(info.logs_page)||1,logsTotal:Number(info.logs_total)||logs.length,logsHasMore:info.logs_has_more===true,logsLoadingMore:false,logsMoreError:false,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false});
|
||||
@@ -50,7 +50,7 @@ Component({
|
||||
avatarFailed(){this.setData({ownerAvatar:''});},
|
||||
retry(){this.load(true);},
|
||||
openDetail(){if(!this.properties.detailed)wx.navigateTo({url:'/pages/smoking-detail/smoking-detail'});},
|
||||
async switchOwner(e){if(this.data.saving)return;this._logsGeneration=(this._logsGeneration||0)+1;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();},
|
||||
async switchOwner(e){if(this.data.saving)return;this._logsGeneration=(this._logsGeneration||0)+1;this.setData({viewUserId:String(e.currentTarget.dataset.user || ""),loading:true,info:null});if(this._loading)await this._loading;await this.load();},
|
||||
replenish(){if(this.data.saving)return;this.setData({sheet:true,mode:'purchase',quantity:'1',packSize:'20',amount:'',note:''});},
|
||||
mode(e){if(this.data.saving)return;const mode=e.currentTarget.dataset.mode;this.setData({mode,quantity:mode==='adjust'?String(this.data.info.stock):'1',amount:'',note:''});},
|
||||
input(e){if(!this.data.saving)this.setData({[e.currentTarget.dataset.field]:e.detail.value});},
|
||||
@@ -74,7 +74,7 @@ Component({
|
||||
if(amount>100000000)return wx.showToast({title:'金额超出范围',icon:'none'});
|
||||
return this.act({kind:mode,quantity,amount_cents:amount,note:this.data.note.trim()});
|
||||
},
|
||||
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:Number(e.currentTarget.dataset.id),quantity:0});}});},
|
||||
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:String(e.currentTarget.dataset.id),quantity:0});}});},
|
||||
async act(payload) {
|
||||
if(this.data.saving||!this.data.info||this.data.info.read_only||!this.data.info.available)return;
|
||||
const signature=JSON.stringify(payload);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// 开发工具使用本机服务;真机调试时把 dev.apiBase 改为电脑的局域网 IP。
|
||||
const environments = {
|
||||
dev: { apiBase: 'http://127.0.0.1:8080/api' },
|
||||
trial: { apiBase: 'https://past.sundynix.cn/api' },
|
||||
release: { apiBase: 'https://past.sundynix.cn/api' }
|
||||
};
|
||||
let version = 'release';
|
||||
try {
|
||||
const account = wx.getAccountInfoSync();
|
||||
version = account.miniProgram.envVersion || 'release';
|
||||
} catch (e) {}
|
||||
const env = version === 'develop' ? 'dev' : version === 'trial' ? 'trial' : 'release';
|
||||
module.exports = { env, API_BASE: environments[env].apiBase };
|
||||
@@ -1,3 +1,4 @@
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { itemsOf, startList, current, mergeUnique, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { currentUserLocation } = require('../../utils/user-location');
|
||||
const { photoForm, metadataRows } = require('../../utils/photo-metadata');
|
||||
@@ -168,7 +169,7 @@ Page(withDataCache(withListPagination({
|
||||
(photos || []).forEach(function(p) {
|
||||
var batchId = p.batch_id || p.created_at || 'default';
|
||||
if (!batchMap[batchId]) {
|
||||
var cid = parseInt(p.created_by) || 0;
|
||||
var cid = String(p.created_by) || 0;
|
||||
batchMap[batchId] = {
|
||||
batch_id: batchId,
|
||||
creator_name: nameMap[cid] || defaultName,
|
||||
@@ -358,7 +359,7 @@ Page(withDataCache(withListPagination({
|
||||
formData: { sub_dir: 'photos' }
|
||||
});
|
||||
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
|
||||
var uploadedPath = uploadRes && (uploadRes.path || uploadRes.url || '');
|
||||
var uploadedPath = uploadReference(uploadRes);
|
||||
if (uploadedPath) {
|
||||
await api.photoAdd({
|
||||
album_id: String(albumId),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/checkin-detail/checkin-detail.js
|
||||
@@ -14,9 +15,9 @@ Page(withDataCache({
|
||||
this.setData(getLayout());
|
||||
var id = options.id;
|
||||
if (id) {
|
||||
this.setData({ detailId: parseInt(id) });
|
||||
this.setData({ detailId: String(id) });
|
||||
this.loadCoupleInfo();
|
||||
this.loadDetail(parseInt(id));
|
||||
this.loadDetail(String(id));
|
||||
}
|
||||
var self = this;
|
||||
var swipe = createSwipeBack(function() { wx.navigateBack(); });
|
||||
@@ -191,13 +192,9 @@ Page(withDataCache({
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'checkins' }
|
||||
});
|
||||
photoPath = upResult.path || '';
|
||||
photoPath = uploadReference(upResult);
|
||||
} catch(e) {
|
||||
// 内容安全违规:抛出错误中断打卡并提示用户
|
||||
if (e && e.error) {
|
||||
throw e;
|
||||
}
|
||||
photoPath = '';
|
||||
throw e;
|
||||
}
|
||||
wx.hideLoading();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ Page(withDataCache({
|
||||
this.setData(getLayout());
|
||||
var diaryId = options.id;
|
||||
if (!diaryId) { wx.showToast({ title: '参数错误', icon: 'none' }); return; }
|
||||
this.setData({ diaryId: parseInt(diaryId), myUid: wx.getStorageSync('userId') || 0 });
|
||||
this.setData({ diaryId: String(diaryId), myUid: wx.getStorageSync('userId') || 0 });
|
||||
this.loadDiary();
|
||||
this.loadComments();
|
||||
},
|
||||
@@ -90,7 +90,7 @@ Page(withDataCache({
|
||||
var result = await api.diaryCommentList({diary_id:this.data.diaryId,page:page,page_size:20});
|
||||
if (generation !== this._commentsGeneration) return;
|
||||
var added = itemsOf(result).map(function(comment) {
|
||||
return Object.assign({}, comment, {_time:String(comment.created_at || comment.create_time || '').slice(0,16).replace('T',' ')});
|
||||
return Object.assign({}, comment, {avatar_url:getImageUrl(comment.avatar_url || comment.avatar || ''),_time:String(comment.created_at || comment.create_time || '').slice(0,16).replace('T',' ')});
|
||||
});
|
||||
this.setData({comments:page===1?added:mergeUnique(this.data.comments,added),commentPage:page,commentsTotal:totalOf(result),hasMoreComments:!Array.isArray(result)&&(result.has_more != null?!!result.has_more:page*20<totalOf(result))});
|
||||
} catch(e) {
|
||||
@@ -186,6 +186,7 @@ Page(withDataCache({
|
||||
this._commentsFirst = false;
|
||||
this.setData({commentsLoadingMore:false});
|
||||
var newComment = res.comment || {};
|
||||
newComment.avatar_url = getImageUrl(newComment.avatar_url || newComment.avatar || "");
|
||||
if (newComment.created_at) {
|
||||
newComment._time = newComment.created_at.substring(0, 16).replace('T', ' ');
|
||||
} else if (newComment.create_time) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
|
||||
const { guardStorageSpace } = require('../../utils/storage-guard');
|
||||
@@ -24,7 +25,7 @@ Page({
|
||||
wx.showToast({ title: '参数错误', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.setData({ diaryId: parseInt(id) });
|
||||
this.setData({ diaryId: String(id) });
|
||||
this.loadDiary();
|
||||
},
|
||||
|
||||
@@ -50,7 +51,7 @@ Page({
|
||||
'form.lng': data.lng || 0,
|
||||
'form.location': data.location || '',
|
||||
tempImages: tempImages,
|
||||
existingImages: imgs,
|
||||
existingImages: Array.isArray(data.image_file_ids) && data.image_file_ids.length === imgs.length ? data.image_file_ids : imgs,
|
||||
contentLength: content.length
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -110,10 +111,10 @@ Page({
|
||||
var existing = this.data.existingImages || [];
|
||||
var temps = this.data.tempImages || [];
|
||||
|
||||
// 判断哪些是新上传的图片(tempFilePaths 以 http 开头的是已有图片,否则是新选的)
|
||||
// 已有图片通过原始引用识别;开发工具的本地图片也可能以 http 开头。
|
||||
for (var i = 0; i < temps.length; i++) {
|
||||
var p = temps[i];
|
||||
if (p.indexOf('http') === 0) {
|
||||
if (existing[i] && getImageUrl(existing[i]) === p) {
|
||||
// 已有图片,用原始路径
|
||||
uploadPaths.push(existing[i] || '');
|
||||
} else {
|
||||
@@ -149,42 +150,16 @@ Page({
|
||||
},
|
||||
|
||||
_uploadImage: async function(filePath) {
|
||||
try {
|
||||
var data = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'diaries' }
|
||||
});
|
||||
var path = data && (data.path || data.url || '');
|
||||
if (!path) {
|
||||
// 内容安全检测失败:抛出错误提示用户
|
||||
if (data && data.error) {
|
||||
throw data;
|
||||
}
|
||||
try {
|
||||
var retryData = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'diaries' }
|
||||
});
|
||||
path = retryData && (retryData.path || retryData.url || '');
|
||||
} catch(retryErr) {
|
||||
console.error('图片上传重试失败', retryErr);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
} catch(e) {
|
||||
// 内容安全违规错误:直接抛出,中断发布并提示用户
|
||||
if (e && e.error) {
|
||||
throw e;
|
||||
}
|
||||
console.error('图片上传失败', e);
|
||||
return '';
|
||||
}
|
||||
var data = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'diaries' }
|
||||
});
|
||||
return uploadReference(data);
|
||||
},
|
||||
|
||||
|
||||
goBack: function() {
|
||||
wx.navigateBack();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { itemsOf, totalOf } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
@@ -359,7 +360,7 @@ Page(withDataCache({
|
||||
hasPartner: true,
|
||||
partnerName: partner.nickname || partner.username || 'TA',
|
||||
partnerDisplayId: partner.user_id || '',
|
||||
partnerId: Number(partner.id),
|
||||
partnerId: String(partner.id),
|
||||
partnerDetail: details.join(' · ') || 'TA的信息',
|
||||
partnerAvatarUrl: getImageUrl(partner.avatar),
|
||||
partnerInitial: (partner.nickname || partner.username || '?')[0],
|
||||
@@ -511,7 +512,7 @@ Page(withDataCache({
|
||||
const data = await api.uploadAvatar(filePath);
|
||||
self.setData({
|
||||
editAvatarUrl: getImageUrl(data.path),
|
||||
editAvatarPath: data.path
|
||||
editAvatarPath: uploadReference(data)
|
||||
});
|
||||
wx.showToast({ title: '头像上传成功 ✨', icon: 'success' });
|
||||
} catch (e) {
|
||||
@@ -663,7 +664,7 @@ Page(withDataCache({
|
||||
self.setData({partnerAvatarUploading:true});
|
||||
try {
|
||||
const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId);
|
||||
self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path});
|
||||
self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploadReference(uploaded)});
|
||||
} catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); }
|
||||
finally { self.setData({partnerAvatarUploading:false}); }
|
||||
}});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/publish/publish.js - 统一发布页(增强版编辑器)
|
||||
const { api, API_BASE, uploadFileCompat } = require('../../utils/request');
|
||||
@@ -50,7 +51,7 @@ Page({
|
||||
onLoad: function(options) {
|
||||
this.setData(getLayout());
|
||||
var type = options.type || 'timeline';
|
||||
var editId = options.edit ? parseInt(options.edit) : 0;
|
||||
var editId = options.edit ? String(options.edit) : "";
|
||||
var cfg = TYPE_CONFIG[type] || TYPE_CONFIG['timeline'];
|
||||
var titleText = editId ? '编辑事件' : cfg.title;
|
||||
var btnText = editId ? '保存修改' : cfg.btn;
|
||||
@@ -173,7 +174,7 @@ Page({
|
||||
repeat_unit: item.repeat_unit || ''
|
||||
};
|
||||
// 加载背景图
|
||||
var bgPath = item.bg_image || '';
|
||||
var bgPath = item.bg_image_file_id || item.bg_image || '';
|
||||
var bgUrl = bgPath ? require('../../utils/request').getImageUrl(bgPath) : '';
|
||||
var repeatOptionIdx = 0;
|
||||
if (form.repeat_interval > 0 && form.repeat_unit) {
|
||||
@@ -384,9 +385,8 @@ Page({
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'timeline_bg' }
|
||||
});
|
||||
bgImagePath = uploadRes && (uploadRes.path || uploadRes.url || '');
|
||||
} catch(e) {}
|
||||
wx.hideLoading();
|
||||
bgImagePath = uploadReference(uploadRes);
|
||||
} finally { wx.hideLoading(); }
|
||||
}
|
||||
var result;
|
||||
if (editId) {
|
||||
@@ -451,93 +451,22 @@ Page({
|
||||
},
|
||||
|
||||
_uploadAlbumCover: async function(filePath) {
|
||||
try {
|
||||
var data = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'albums' }
|
||||
});
|
||||
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
|
||||
var path = data && (data.path || data.url || '');
|
||||
if (!path) {
|
||||
// 内容安全检测失败:抛出错误提示用户
|
||||
if (data && data.error) {
|
||||
throw data;
|
||||
}
|
||||
console.error('封面上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
|
||||
// 尝试重新上传一次
|
||||
try {
|
||||
var retryData = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'albums' }
|
||||
});
|
||||
var retryPath = retryData && (retryData.path || retryData.url || '');
|
||||
if (retryPath) {
|
||||
console.log('封面上传重试成功');
|
||||
return retryPath;
|
||||
}
|
||||
} catch(retryErr) {
|
||||
console.error('封面上传重试也失败', retryErr, '错误码:', retryErr && retryErr.code);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
} catch(e) {
|
||||
// 内容安全违规错误:直接抛出,中断发布并提示用户
|
||||
if (e && e.error) {
|
||||
throw e;
|
||||
}
|
||||
console.error('封面上传失败', e, '错误码:', e && e.code);
|
||||
return '';
|
||||
}
|
||||
return this._uploadMedia(filePath, 'albums');
|
||||
},
|
||||
|
||||
_uploadDiaryImage: async function(filePath) {
|
||||
try {
|
||||
var data = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'diaries' }
|
||||
});
|
||||
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
|
||||
var path = data && (data.path || data.url || '');
|
||||
if (!path) {
|
||||
// 内容安全检测失败:抛出错误提示用户
|
||||
if (data && data.error) {
|
||||
throw data;
|
||||
}
|
||||
console.error('日记图片上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
|
||||
// 尝试重新上传一次
|
||||
try {
|
||||
var retryData = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: 'diaries' }
|
||||
});
|
||||
var retryPath = retryData && (retryData.path || retryData.url || '');
|
||||
if (retryPath) {
|
||||
console.log('日记图片重试上传成功');
|
||||
return retryPath;
|
||||
}
|
||||
} catch(retryErr) {
|
||||
console.error('日记图片重试上传也失败', retryErr, '错误码:', retryErr && retryErr.code);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
} catch(e) {
|
||||
// 内容安全违规错误:直接抛出,中断发布并提示用户
|
||||
if (e && e.error) {
|
||||
throw e;
|
||||
}
|
||||
console.error('日记图片上传失败', e, '错误码:', e && e.code);
|
||||
return '';
|
||||
}
|
||||
return this._uploadMedia(filePath, 'diaries');
|
||||
},
|
||||
|
||||
_uploadMedia: async function(filePath, subDir) {
|
||||
var data = await uploadFileCompat({
|
||||
url: API_BASE + '/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: { sub_dir: subDir }
|
||||
});
|
||||
return uploadReference(data);
|
||||
},
|
||||
// ===== 背景图 =====
|
||||
chooseBgImage: function() {
|
||||
var self = this;
|
||||
|
||||
@@ -74,8 +74,8 @@ Page(withDataCache({
|
||||
this.setData(getLayout());
|
||||
var id = options.id;
|
||||
if (id) {
|
||||
this.setData({ detailId: parseInt(id) });
|
||||
this.loadDetail(parseInt(id));
|
||||
this.setData({ detailId: String(id) });
|
||||
this.loadDetail(String(id));
|
||||
}
|
||||
var self = this;
|
||||
var swipe = createSwipeBack(function() { wx.navigateBack(); });
|
||||
@@ -108,7 +108,7 @@ Page(withDataCache({
|
||||
try {
|
||||
var item = await api.timelineDetail(id);
|
||||
var myUid = wx.getStorageSync('userId');
|
||||
var cid = parseInt(item.created_by) || 0;
|
||||
var cid = String(item.created_by) || 0;
|
||||
|
||||
// 获取情侣信息
|
||||
try {
|
||||
|
||||
@@ -101,7 +101,7 @@ Page(withDataCache(withListPagination({
|
||||
|
||||
var today = new Date(); today.setHours(0,0,0,0);
|
||||
var items = itemsOf(list).map(function(item, idx) {
|
||||
var cid = parseInt(item.created_by) || 0;
|
||||
var cid = String(item.created_by) || 0;
|
||||
item.creator_name = nameMap[cid] || (cid == myUid ? '我' : 'TA');
|
||||
item.creator_avatar = avatarMap[cid] || '';
|
||||
item.creator_initial = (item.creator_name || '?')[0].toUpperCase();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compileType": "miniprogram",
|
||||
"appid": "wxdc40d0c51cf8fe1e",
|
||||
"appid": "wxb463820bf36dd5d6",
|
||||
"projectname": "couple-space",
|
||||
"description": "情侣空间小程序",
|
||||
"setting": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const loader = require('./load-module.cjs');
|
||||
test('development requests and file previews use local backend, trial and release use production', () => {
|
||||
for (const version of ['develop','trial','release']) {
|
||||
const wx = {getAccountInfoSync:()=>({miniProgram:{envVersion:version}})};
|
||||
const load = loader({wx,console});
|
||||
const env = load(path.join(__dirname,'../config/env.js'));
|
||||
const {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js'));
|
||||
assert.equal(env.env, version === 'develop' ? 'dev' : version);
|
||||
assert.equal(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://past.sundynix.cn/api');
|
||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const createLoader = require('./load-module.cjs');
|
||||
const {uploadReference} = require('../utils/file-media');
|
||||
const id = '6083e119-6b26-441b-8b70-44d224366238';
|
||||
const id2 = '7083e119-6b26-441b-8b70-44d224366238';
|
||||
function client(extra = {}) {
|
||||
const calls = [];
|
||||
const load = createLoader({console, Promise, Date, wx: {
|
||||
getStorageSync: () => 'token', hideLoading() {},
|
||||
request(o) { calls.push(o); o.success({statusCode:200, data:{success:true}}); }, ...extra
|
||||
}});
|
||||
return {...load(path.join(__dirname, '../utils/request.js')), calls};
|
||||
}
|
||||
test('business requests persist IDs, preserve order and distinguish clear from omitted fields', async () => {
|
||||
const c = client();
|
||||
await c.api.diaryUpdate({id:'diary',images:['https://past.sundynix.cn/api/files/'+id, id2]});
|
||||
await c.api.albumUpdate({id:'album',cover:''});
|
||||
await c.api.updateProfile({nickname:'test'});
|
||||
await c.api.photoAdd({album_id:'album',path:'/api/files/'+id});
|
||||
await c.api.checkinDo({id:'checkin',photo:id});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(c.calls[0].data)), {id:'diary',imageFileIds:[id,id2]});
|
||||
assert.equal(c.calls[1].data.coverFileId,'');
|
||||
assert.equal('avatarFileId' in c.calls[2].data,false);
|
||||
assert.equal(c.calls[3].data.fileId,id);
|
||||
assert.equal(c.calls[4].data.photoFileId,id);
|
||||
});
|
||||
test('duplicate and ID-only uploads keep stable display URLs and file metadata', async () => {
|
||||
const c = client({compressImage(o){o.fail();},uploadFile(o){o.success({statusCode:200,data:JSON.stringify({success:true,duplicate:true,file:{id,url:'https://minio/raw-object'}})});}});
|
||||
const result = await c.uploadFileCompat({url:c.API_BASE+'/upload',filePath:'http://tmp/new.jpg'});
|
||||
assert.equal(uploadReference(result),id);
|
||||
assert.equal(result.duplicate,true);
|
||||
assert.equal(result.file.url,'https://minio/raw-object');
|
||||
assert.equal(c.getImageUrl(result.path),'https://past.sundynix.cn/api/files/'+id);
|
||||
assert.throws(()=>uploadReference({success:true,path:'http://tmp/new.jpg'}),/文件 ID/);
|
||||
});
|
||||
test('diary edit uploads HTTP temporary images, retains existing IDs and stops on upload failure', async () => {
|
||||
let definition, saved, uploads=[];
|
||||
const c = client();
|
||||
const load = createLoader({console, Date, Promise, setTimeout(){}, Page(p){definition=p;}, wx:{showLoading(){},hideLoading(){},showToast(){}}}, {
|
||||
'../../utils/request':{getImageUrl:c.getImageUrl,api:{async diaryUpdate(data){saved=data;}}}
|
||||
});
|
||||
load(path.join(__dirname,'../pages/diary_edit/diary_edit.js'));
|
||||
const p = {...definition, data:{loading:false,form:{content:'text'},existingImages:[id],tempImages:[c.getImageUrl(id),'http://tmp/new.jpg']},setData(v){Object.assign(this.data,v);},async _uploadImage(v){uploads.push(v);return id2;}};
|
||||
await p.submit();
|
||||
assert.deepEqual(uploads,['http://tmp/new.jpg']);
|
||||
assert.deepEqual(Array.from(saved.images),[id,id2]);
|
||||
saved=null;p._uploadImage=async()=>{throw Error('network');};
|
||||
await p.submit();assert.equal(saved,null);
|
||||
});
|
||||
|
||||
test('media display keeps local assets and temporary paths without a backend directory prefix', () => {
|
||||
const c = client();
|
||||
assert.equal(c.getImageUrl('/images/default-avatar.png'), '/images/default-avatar.png');
|
||||
assert.equal(c.getImageUrl('wxfile://tmp/photo.jpg'), 'wxfile://tmp/photo.jpg');
|
||||
assert.equal(c.getImageUrl('http://tmp/photo.jpg'), 'http://tmp/photo.jpg');
|
||||
assert.equal(c.getImageUrl(id), 'https://past.sundynix.cn/api/files/' + id);
|
||||
});
|
||||
@@ -35,7 +35,7 @@ test('server relation changes discard cached data before another read',async()=>
|
||||
await api.diaryList();await api.diaryList();assert.equal(reads,1);
|
||||
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
|
||||
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
|
||||
assert.equal(getImageUrl('/backend/uploads/a.jpg?media_sig=abc'),'https://past.sundynix.cn/backend/uploads/a.jpg?media_sig=abc');
|
||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://past.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||
});
|
||||
|
||||
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
|
||||
|
||||
@@ -25,7 +25,7 @@ test('purchase converts packs to pieces and cents; invalid quantities never subm
|
||||
e.component.setData({quantity:'1.5'});await e.component.submit();assert.equal(e.calls.length,1);
|
||||
});
|
||||
test('undo includes the original record and fills a seven-day chart with zero days',async()=>{
|
||||
const e=setup(async()=>result());e.component.undo({currentTarget:{dataset:{id:17}}});await Promise.resolve();await Promise.resolve();assert.equal(e.calls[0].kind,'undo');assert.equal(e.calls[0].original_id,17);assert.equal(e.component.data.daily.length,7);
|
||||
const e=setup(async()=>result());e.component.undo({currentTarget:{dataset:{id:'6083e119-6b26-441b-8b70-44d224366238'}}});await Promise.resolve();await Promise.resolve();assert.equal(e.calls[0].kind,'undo');assert.equal(e.calls[0].original_id,'6083e119-6b26-441b-8b70-44d224366238');assert.equal(e.component.data.daily.length,7);
|
||||
});
|
||||
|
||||
test('string zero is active while one is undone, and ledger identity follows the selected account',async()=>{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// File IDs are persisted; stable API URLs are used for display only.
|
||||
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||
function fileId(value) {
|
||||
if (value && typeof value === 'object') return fileId(value.file_id || value.fileId || (value.file && value.file.id) || value.id || value.path || value.url);
|
||||
if (typeof value !== 'string') return '';
|
||||
if (new RegExp('^' + UUID + '$', 'i').test(value)) return value;
|
||||
var match = value.match(new RegExp('^(?:https?://[^/]+)?/api/files/(' + UUID + ')(?:[?#].*)?$', 'i'));
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
function uploadReference(value) {
|
||||
if (!value || value.error) throw value || new Error('上传失败');
|
||||
var id = fileId(value);
|
||||
if (!id) throw new Error('上传响应缺少文件 ID,请重试');
|
||||
return id;
|
||||
}
|
||||
function normalizeUploadResult(value) {
|
||||
if (!value || typeof value !== 'object' || value.error) return value;
|
||||
var id = fileId(value);
|
||||
if (!id) return value;
|
||||
return Object.assign({}, value, { file_id: id, path: '/api/files/' + id, url: '/api/files/' + id });
|
||||
}
|
||||
function filePayload(action, data) {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
var mappings = {
|
||||
updateProfile: { avatar: 'avatar_file_id' }, updatePartnerProfile: { avatar: 'avatar_file_id' },
|
||||
albumAdd: { cover: 'cover_file_id' }, albumUpdate: { cover: 'cover_file_id' },
|
||||
photoAdd: { path: 'file_id' }, diaryAdd: { images: 'image_file_ids' }, diaryUpdate: { images: 'image_file_ids' },
|
||||
timelineAdd: { bg_image: 'bg_image_file_id', images: 'image_file_ids' },
|
||||
timelineUpdate: { bg_image: 'bg_image_file_id', images: 'image_file_ids' }, checkinDo: { photo: 'photo_file_id' }
|
||||
};
|
||||
var out = Object.assign({}, data);
|
||||
Object.keys(mappings[action] || {}).forEach(function(key) {
|
||||
if (!Object.prototype.hasOwnProperty.call(out, key)) return;
|
||||
var target = mappings[action][key];
|
||||
var value = out[key];
|
||||
// Legacy references remain compatible; all new uploads supply UUIDs.
|
||||
var convert = function(v) { return fileId(v) || v; };
|
||||
if (!Object.prototype.hasOwnProperty.call(out, target)) out[target] = Array.isArray(value) ? value.map(convert) : convert(value);
|
||||
delete out[key];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
module.exports = { fileId, uploadReference, normalizeUploadResult, filePayload };
|
||||
+15
-14
@@ -1,22 +1,23 @@
|
||||
const { fileId, normalizeUploadResult, filePayload } = require('./file-media');
|
||||
const { camelName, toWire, fromWire } = require('./api-naming');
|
||||
const dataCache = require('./data-cache');
|
||||
/**
|
||||
* 网络请求工具 - 对接后端 API
|
||||
*/
|
||||
const API_BASE = 'https://past.sundynix.cn/api';
|
||||
const MEDIA_BASE = 'https://past.sundynix.cn/backend/';
|
||||
const { API_BASE } = require('../config/env');
|
||||
|
||||
/**
|
||||
* 将相对路径转为完整URL(供小程序端显示图片使用)
|
||||
* @param {string} path - 相对路径,如 "uploads/avatars/xxx.jpg"
|
||||
* @returns {string} 完整URL,如 "http://192.168.2.103/uploads/avatars/xxx.jpg"
|
||||
* 将文件 UUID 或 /api/files/{id} 转成完整访问地址。
|
||||
* 完整 URL 和小程序本地图片路径直接使用。
|
||||
*/
|
||||
function getImageUrl(path) {
|
||||
if (!path) return '';
|
||||
if (typeof path === 'object') path = fileId(path) || path.path || path.url || '';
|
||||
if (typeof path !== 'string') return '';
|
||||
if (path.startsWith('/api/files/')) return API_BASE.replace(/\/api$/, '') + path;
|
||||
if (fileId(path) === path) return API_BASE + '/files/' + path;
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
if (path.startsWith('/backend/')) return MEDIA_BASE + path.substring('/backend/'.length);
|
||||
if (path.startsWith('/')) return MEDIA_BASE + path.substring(1);
|
||||
return MEDIA_BASE + path;
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,23 +27,23 @@ function getImageUrl(path) {
|
||||
*/
|
||||
function parseUploadResponse(raw) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return fromWire(raw);
|
||||
if (typeof raw === 'object') return normalizeUploadResult(fromWire(raw));
|
||||
var str = String(raw).trim();
|
||||
// 去除 BOM 和不可见字符
|
||||
str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
||||
try {
|
||||
return fromWire(JSON.parse(str));
|
||||
return normalizeUploadResult(fromWire(JSON.parse(str)));
|
||||
} catch(e) {
|
||||
// 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应)
|
||||
var match = str.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try { return fromWire(JSON.parse(match[0])); } catch(e2) {}
|
||||
try { return normalizeUploadResult(fromWire(JSON.parse(match[0]))); } catch(e2) {}
|
||||
}
|
||||
// 最后尝试:查找 "success" 或 "path" 关键词,手动构建响应对象
|
||||
if (str.indexOf('success') > -1 || str.indexOf('path') > -1) {
|
||||
var pathMatch = str.match(/"path"\s*:\s*"([^"]+)"/);
|
||||
if (pathMatch) {
|
||||
return { success: true, path: pathMatch[1] };
|
||||
return normalizeUploadResult({ success: true, path: pathMatch[1] });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -129,7 +130,7 @@ function sendRequest(action, data = null, method = 'POST') {
|
||||
}
|
||||
|
||||
let url = API_BASE + '/' + encodeURIComponent(camelName(action));
|
||||
let requestData = toWire(data);
|
||||
let requestData = toWire(filePayload(camelName(action), data));
|
||||
|
||||
if (method === 'GET' && data) {
|
||||
const params = [];
|
||||
@@ -385,7 +386,7 @@ const api = {
|
||||
songsDelete: (data) => request('songsDelete', data),
|
||||
|
||||
// 打卡
|
||||
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || 0 }, "GET"),
|
||||
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || "" }, "GET"),
|
||||
smokingAction: (data) => request("smokingAction", data),
|
||||
checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
|
||||
checkinAdd: (data) => request('checkinAdd', data),
|
||||
|
||||
Reference in New Issue
Block a user