From 4934a69581b589767ffb358eb4c61dc00407b90a Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sun, 1 Nov 2026 11:48:45 +0800 Subject: [PATCH] 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. --- .gitignore | 1 + past-go/Makefile | 6 +- past-go/README.md | 56 +- past-go/cmd/dbcheck/main.go | 65 +- past-go/cmd/migrate/main.go | 40 ++ past-go/cmd/server/main.go | 13 +- past-go/config.dev.example.yaml | 37 ++ past-go/config.example.yaml | 19 +- past-go/deploy/README.md | 27 + past-go/deploy/diagnose-data-visibility.sql | 112 +++- past-go/deploy/windows/README.md | 27 + past-go/go.mod | 35 +- past-go/go.sum | 87 ++- past-go/internal/config/config.go | 66 +- past-go/internal/config/config_test.go | 44 +- past-go/internal/handler/admin.go | 114 ++-- past-go/internal/handler/admin_files.go | 94 +++ past-go/internal/handler/admin_subscribe.go | 2 +- .../internal/handler/admin_subscribe_test.go | 2 +- past-go/internal/handler/album.go | 98 +-- past-go/internal/handler/api_postgres_test.go | 606 ++++++++++++++++++ past-go/internal/handler/checkin.go | 51 +- past-go/internal/handler/common.go | 79 +-- past-go/internal/handler/couple.go | 106 ++- past-go/internal/handler/diary.go | 40 +- .../internal/handler/list_pagination_test.go | 2 +- past-go/internal/handler/misc.go | 63 +- past-go/internal/handler/period.go | 46 +- past-go/internal/handler/period_source.go | 48 +- past-go/internal/handler/permissions_test.go | 4 +- past-go/internal/handler/pet.go | 43 +- past-go/internal/handler/smoking.go | 42 +- .../internal/handler/smoking_stats_test.go | 6 +- past-go/internal/handler/songs.go | 4 +- past-go/internal/handler/storage.go | 225 +++---- past-go/internal/handler/timeline.go | 16 +- past-go/internal/handler/user.go | 71 +- past-go/internal/httpapi/auth.go | 38 +- past-go/internal/httpapi/ctx.go | 35 +- past-go/internal/httpapi/media.go | 13 + past-go/internal/httpapi/router.go | 21 +- past-go/internal/httpapi/router_test.go | 2 +- past-go/internal/model/files.go | 39 ++ past-go/internal/model/models.go | 474 ++++++++++++++ past-go/internal/model/relations_test.go | 43 ++ past-go/internal/notify/notify.go | 8 +- past-go/internal/notify/notify_test.go | 10 +- past-go/internal/store/db.go | 327 +++++----- past-go/internal/store/files.go | 363 +++++++++++ .../store/postgres_integration_test.go | 289 +++++++++ past-go/internal/store/relationships.go | 33 +- past-go/internal/store/relationships_test.go | 45 +- past-go/internal/store/repository.go | 407 ++++++++++++ past-go/internal/store/repository_test.go | 41 ++ past-go/internal/store/schema.go | 234 ++----- past-go/internal/store/schema_sql.go | 514 --------------- past-go/internal/store/unbinding_cleanup.go | 128 +--- .../internal/store/unbinding_cleanup_test.go | 60 +- past-go/internal/testdb/script.go | 107 +++- .../internal/upload/minio_integration_test.go | 296 +++++++++ past-go/internal/upload/upload.go | 344 ++++++---- past-go/internal/wechat/subscribe_test.go | 21 +- past-go/internal/wechat/wechat.go | 27 +- past-go/web/admin/src/App.tsx | 2 + .../src/components/CoupleDetailDialog.tsx | 6 +- past-go/web/admin/src/components/Layout.tsx | 3 +- .../web/admin/src/components/UserDialogs.tsx | 8 +- past-go/web/admin/src/lib/api.ts | 8 +- past-go/web/admin/src/lib/media.ts | 19 + past-go/web/admin/src/pages/Backgrounds.tsx | 10 +- past-go/web/admin/src/pages/Couples.tsx | 2 +- past-go/web/admin/src/pages/Files.tsx | 60 ++ past-go/web/admin/src/pages/Pets.tsx | 2 +- past-go/web/admin/src/pages/Storage.tsx | 8 +- .../web/admin/src/pages/SubscribeMessages.tsx | 2 +- past-go/web/admin/src/pages/Users.tsx | 6 +- past-go/web/admin/tests/media.test.mjs | 14 + weapp/components/smoking-card/smoking-card.js | 10 +- weapp/config/env.js | 13 + weapp/pages/album/album.js | 5 +- weapp/pages/checkin-detail/checkin-detail.js | 13 +- weapp/pages/diary_detail/diary_detail.js | 5 +- weapp/pages/diary_edit/diary_edit.js | 51 +- weapp/pages/profile/profile.js | 7 +- weapp/pages/publish/publish.js | 103 +-- .../pages/timeline-detail/timeline-detail.js | 6 +- weapp/pages/timeline/timeline.js | 2 +- weapp/project.config.json | 2 +- weapp/tests/env.test.cjs | 15 + weapp/tests/file-media.test.cjs | 59 ++ weapp/tests/permissions.test.cjs | 2 +- weapp/tests/smoking.test.cjs | 2 +- weapp/utils/file-media.js | 43 ++ weapp/utils/request.js | 29 +- 94 files changed, 4686 insertions(+), 2147 deletions(-) create mode 100644 past-go/cmd/migrate/main.go create mode 100644 past-go/config.dev.example.yaml create mode 100644 past-go/internal/handler/admin_files.go create mode 100644 past-go/internal/handler/api_postgres_test.go create mode 100644 past-go/internal/model/files.go create mode 100644 past-go/internal/model/models.go create mode 100644 past-go/internal/model/relations_test.go create mode 100644 past-go/internal/store/files.go create mode 100644 past-go/internal/store/postgres_integration_test.go create mode 100644 past-go/internal/store/repository.go create mode 100644 past-go/internal/store/repository_test.go delete mode 100644 past-go/internal/store/schema_sql.go create mode 100644 past-go/internal/upload/minio_integration_test.go create mode 100644 past-go/web/admin/src/lib/media.ts create mode 100644 past-go/web/admin/src/pages/Files.tsx create mode 100644 past-go/web/admin/tests/media.test.mjs create mode 100644 weapp/config/env.js create mode 100644 weapp/tests/env.test.cjs create mode 100644 weapp/tests/file-media.test.cjs create mode 100644 weapp/utils/file-media.js diff --git a/.gitignore b/.gitignore index 55271d2..1b582bd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ run/ # 本地配置(含密钥,不入库) config.yaml +config.dev.yaml # 生产部署配置模板(含数据库密码,不入库;部署时复制到服务器后按需修改) deploy/linux/config.prod.yaml diff --git a/past-go/Makefile b/past-go/Makefile index 42aeb4c..dc6dcae 100644 --- a/past-go/Makefile +++ b/past-go/Makefile @@ -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 ./... diff --git a/past-go/README.md b/past-go/README.md index 5098d21..3daa7bb 100644 --- a/past-go/README.md +++ b/past-go/README.md @@ -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/.`。同内容并发上传通过 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 等价物;文件落在 /uploads/ +internal/upload/ MinIO 上传、MD5 去重、日期目录与签名访问 internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session internal/geo/ 腾讯位置服务 internal/notify/ cron_notify.php 的进程内定时版 diff --git a/past-go/cmd/dbcheck/main.go b/past-go/cmd/dbcheck/main.go index e8e44a5..65be306 100644 --- a/past-go/cmd/dbcheck/main.go +++ b/past-go/cmd/dbcheck/main.go @@ -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 { diff --git a/past-go/cmd/migrate/main.go b/past-go/cmd/migrate/main.go new file mode 100644 index 0000000..e56c13b --- /dev/null +++ b/past-go/cmd/migrate/main.go @@ -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) + } +} diff --git a/past-go/cmd/server/main.go b/past-go/cmd/server/main.go index 3195a6b..230dc4e 100644 --- a/past-go/cmd/server/main.go +++ b/past-go/cmd/server/main.go @@ -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) diff --git a/past-go/config.dev.example.yaml b/past-go/config.dev.example.yaml new file mode 100644 index 0000000..0615420 --- /dev/null +++ b/past-go/config.dev.example.yaml @@ -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 diff --git a/past-go/config.example.yaml b/past-go/config.example.yaml index 0538329..c0997b2 100644 --- a/past-go/config.example.yaml +++ b/past-go/config.example.yaml @@ -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 diff --git a/past-go/deploy/README.md b/past-go/deploy/README.md index 77a17e6..0f3c93c 100644 --- a/past-go/deploy/README.md +++ b/past-go/deploy/README.md @@ -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 原权限兼容与抽烟功能 ## 当前行为 diff --git a/past-go/deploy/diagnose-data-visibility.sql b/past-go/deploy/diagnose-data-visibility.sql index 4619de8..2009c94 100644 --- a/past-go/deploy/diagnose-data-visibility.sql +++ b/past-go/deploy/diagnose-data-visibility.sql @@ -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 diff --git a/past-go/deploy/windows/README.md b/past-go/deploy/windows/README.md index 50f42bb..9da4e0e 100644 --- a/past-go/deploy/windows/README.md +++ b/past-go/deploy/windows/README.md @@ -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`, diff --git a/past-go/go.mod b/past-go/go.mod index c2b02cc..2c7d280 100644 --- a/past-go/go.mod +++ b/past-go/go.mod @@ -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 ) diff --git a/past-go/go.sum b/past-go/go.sum index 9823e18..f2c959c 100644 --- a/past-go/go.sum +++ b/past-go/go.sum @@ -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= diff --git a/past-go/internal/config/config.go b/past-go/internal/config/config.go index 827f589..b82b11b 100644 --- a/past-go/internal/config/config.go +++ b/past-go/internal/config/config.go @@ -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() } diff --git a/past-go/internal/config/config_test.go b/past-go/internal/config/config_test.go index 1ba21eb..3ca9966 100644 --- a/past-go/internal/config/config_test.go +++ b/past-go/internal/config/config_test.go @@ -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") } } diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go index 08e6ad1..b26bae4 100644 --- a/past-go/internal/handler/admin.go +++ b/past-go/internal/handler/admin.go @@ -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}) diff --git a/past-go/internal/handler/admin_files.go b/past-go/internal/handler/admin_files.go new file mode 100644 index 0000000..72b9bcd --- /dev/null +++ b/past-go/internal/handler/admin_files.go @@ -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 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)))}) +} diff --git a/past-go/internal/handler/admin_subscribe.go b/past-go/internal/handler/admin_subscribe.go index b56c689..87db193 100644 --- a/past-go/internal/handler/admin_subscribe.go +++ b/past-go/internal/handler/admin_subscribe.go @@ -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 } diff --git a/past-go/internal/handler/admin_subscribe_test.go b/past-go/internal/handler/admin_subscribe_test.go index 28c7056..fd1d928 100644 --- a/past-go/internal/handler/admin_subscribe_test.go +++ b/past-go/internal/handler/admin_subscribe_test.go @@ -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`, `用户未订阅`} { diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go index f4898ab..c7dfa54 100644 --- a/past-go/internal/handler/album.go +++ b/past-go/internal/handler/album.go @@ -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 } diff --git a/past-go/internal/handler/api_postgres_test.go b/past-go/internal/handler/api_postgres_test.go new file mode 100644 index 0000000..43fb699 --- /dev/null +++ b/past-go/internal/handler/api_postgres_test.go @@ -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 := "xpay_goods_deliver_notify" + store.ToStr(yearOrder["out_trade_no"]) + "wx-year-order" + 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(), "0") { + 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 +} diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index 44f9e13..6acc19d 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -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) diff --git a/past-go/internal/handler/common.go b/past-go/internal/handler/common.go index b332da4..8c9d649 100644 --- a/past-go/internal/handler/common.go +++ b/past-go/internal/handler/common.go @@ -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{ {"日常甜蜜", "我们的小日常"}, {"旅行记录", "一起走过的风景"}, diff --git a/past-go/internal/handler/couple.go b/past-go/internal/handler/couple.go index 5825370..c85f5a7 100644 --- a/past-go/internal/handler/couple.go +++ b/past-go/internal/handler/couple.go @@ -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}) diff --git a/past-go/internal/handler/diary.go b/past-go/internal/handler/diary.go index 9b3d40f..d027a3d 100644 --- a/past-go/internal/handler/diary.go +++ b/past-go/internal/handler/diary.go @@ -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 { diff --git a/past-go/internal/handler/list_pagination_test.go b/past-go/internal/handler/list_pagination_test.go index f7c62b1..2ba6d53 100644 --- a/past-go/internal/handler/list_pagination_test.go +++ b/past-go/internal/handler/list_pagination_test.go @@ -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}, } { diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go index 75828c8..29973a7 100644 --- a/past-go/internal/handler/misc.go +++ b/past-go/internal/handler/misc.go @@ -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}) diff --git a/past-go/internal/handler/period.go b/past-go/internal/handler/period.go index 57d61af..33b11e6 100644 --- a/past-go/internal/handler/period.go +++ b/past-go/internal/handler/period.go @@ -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 { diff --git a/past-go/internal/handler/period_source.go b/past-go/internal/handler/period_source.go index a32df62..fcafd66 100644 --- a/past-go/internal/handler/period_source.go +++ b/past-go/internal/handler/period_source.go @@ -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 } diff --git a/past-go/internal/handler/permissions_test.go b/past-go/internal/handler/permissions_test.go index c464182..a95baef 100644 --- a/past-go/internal/handler/permissions_test.go +++ b/past-go/internal/handler/permissions_test.go @@ -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 { diff --git a/past-go/internal/handler/pet.go b/past-go/internal/handler/pet.go index 0105fdd..43054ff 100644 --- a/past-go/internal/handler/pet.go +++ b/past-go/internal/handler/pet.go @@ -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) diff --git a/past-go/internal/handler/smoking.go b/past-go/internal/handler/smoking.go index 50a7e98..89c7210 100644 --- a/past-go/internal/handler/smoking.go +++ b/past-go/internal/handler/smoking.go @@ -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 diff --git a/past-go/internal/handler/smoking_stats_test.go b/past-go/internal/handler/smoking_stats_test.go index a10b503..6a08be3 100644 --- a/past-go/internal/handler/smoking_stats_test.go +++ b/past-go/internal/handler/smoking_stats_test.go @@ -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) } diff --git a/past-go/internal/handler/songs.go b/past-go/internal/handler/songs.go index 5e4b9d9..b491e7e 100644 --- a/past-go/internal/handler/songs.go +++ b/past-go/internal/handler/songs.go @@ -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) diff --git a/past-go/internal/handler/storage.go b/past-go/internal/handler/storage.go index ef1ab38..4e0ab76 100644 --- a/past-go/internal/handler/storage.go +++ b/past-go/internal/handler/storage.go @@ -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 diff --git a/past-go/internal/handler/timeline.go b/past-go/internal/handler/timeline.go index cf523e6..e66e39e 100644 --- a/past-go/internal/handler/timeline.go +++ b/past-go/internal/handler/timeline.go @@ -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) + "」", diff --git a/past-go/internal/handler/user.go b/past-go/internal/handler/user.go index 1924442..632c87e 100644 --- a/past-go/internal/handler/user.go +++ b/past-go/internal/handler/user.go @@ -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 } diff --git a/past-go/internal/httpapi/auth.go b/past-go/internal/httpapi/auth.go index 2606aa6..6730c62 100644 --- a/past-go/internal/httpapi/auth.go +++ b/past-go/internal/httpapi/auth.go @@ -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 } diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go index 049ba65..8d1b711 100644 --- a/past-go/internal/httpapi/ctx.go +++ b/past-go/internal/httpapi/ctx.go @@ -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) } } } diff --git a/past-go/internal/httpapi/media.go b/past-go/internal/httpapi/media.go index 52925a8..8e4071b 100644 --- a/past-go/internal/httpapi/media.go +++ b/past-go/internal/httpapi/media.go @@ -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 + } + } +} diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go index 51353f9..2f92896 100644 --- a/past-go/internal/httpapi/router.go +++ b/past-go/internal/httpapi/router.go @@ -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, "未知操作") diff --git a/past-go/internal/httpapi/router_test.go b/past-go/internal/httpapi/router_test.go index c2d43b3..01debcc 100644 --- a/past-go/internal/httpapi/router_test.go +++ b/past-go/internal/httpapi/router_test.go @@ -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}) }) diff --git a/past-go/internal/model/files.go b/past-go/internal/model/files.go new file mode 100644 index 0000000..5ef0678 --- /dev/null +++ b/past-go/internal/model/files.go @@ -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:"-"` +} diff --git a/past-go/internal/model/models.go b/past-go/internal/model/models.go new file mode 100644 index 0000000..93ab14f --- /dev/null +++ b/past-go/internal/model/models.go @@ -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{}} +} diff --git a/past-go/internal/model/relations_test.go b/past-go/internal/model/relations_test.go new file mode 100644 index 0000000..7d87a6c --- /dev/null +++ b/past-go/internal/model/relations_test.go @@ -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") + } + } + } + } +} diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go index f8194ac..a37cf63 100644 --- a/past-go/internal/notify/notify.go +++ b/past-go/internal/notify/notify.go @@ -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 diff --git a/past-go/internal/notify/notify_test.go b/past-go/internal/notify/notify_test.go index 436afbf..50fe49a 100644 --- a/past-go/internal/notify/notify_test.go +++ b/past-go/internal/notify/notify_test.go @@ -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) } }) } diff --git a/past-go/internal/store/db.go b/past-go/internal/store/db.go index 9c0428b..c8701cf 100644 --- a/past-go/internal/store/db.go +++ b/past-go/internal/store/db.go @@ -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() -} diff --git a/past-go/internal/store/files.go b/past-go/internal/store/files.go new file mode 100644 index 0000000..dbdc21f --- /dev/null +++ b/past-go/internal/store/files.go @@ -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 +} diff --git a/past-go/internal/store/postgres_integration_test.go b/past-go/internal/store/postgres_integration_test.go new file mode 100644 index 0000000..b8969e2 --- /dev/null +++ b/past-go/internal/store/postgres_integration_test.go @@ -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) +} diff --git a/past-go/internal/store/relationships.go b/past-go/internal/store/relationships.go index afe6bee..217cfae 100644 --- a/past-go/internal/store/relationships.go +++ b/past-go/internal/store/relationships.go @@ -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() diff --git a/past-go/internal/store/relationships_test.go b/past-go/internal/store/relationships_test.go index 8bedaf0..2c32fde 100644 --- a/past-go/internal/store/relationships_test.go +++ b/past-go/internal/store/relationships_test.go @@ -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"}}, - } -} diff --git a/past-go/internal/store/repository.go b/past-go/internal/store/repository.go new file mode 100644 index 0000000..dcdee04 --- /dev/null +++ b/past-go/internal/store/repository.go @@ -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 +} diff --git a/past-go/internal/store/repository_test.go b/past-go/internal/store/repository_test.go new file mode 100644 index 0000000..ceedf11 --- /dev/null +++ b/past-go/internal/store/repository_test.go @@ -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") + } +} diff --git a/past-go/internal/store/schema.go b/past-go/internal/store/schema.go index c144642..2e603d5 100644 --- a/past-go/internal/store/schema.go +++ b/past-go/internal/store/schema.go @@ -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 + }) } diff --git a/past-go/internal/store/schema_sql.go b/past-go/internal/store/schema_sql.go deleted file mode 100644 index c75206e..0000000 --- a/past-go/internal/store/schema_sql.go +++ /dev/null @@ -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隐藏'"}, -} diff --git a/past-go/internal/store/unbinding_cleanup.go b/past-go/internal/store/unbinding_cleanup.go index 2a7d20d..315535f 100644 --- a/past-go/internal/store/unbinding_cleanup.go +++ b/past-go/internal/store/unbinding_cleanup.go @@ -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_', ?))", } diff --git a/past-go/internal/store/unbinding_cleanup_test.go b/past-go/internal/store/unbinding_cleanup_test.go index 2bda726..2ccc914 100644 --- a/past-go/internal/store/unbinding_cleanup_test.go +++ b/past-go/internal/store/unbinding_cleanup_test.go @@ -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") - } } diff --git a/past-go/internal/testdb/script.go b/past-go/internal/testdb/script.go index 013a13d..0e361b0 100644 --- a/past-go/internal/testdb/script.go +++ b/past-go/internal/testdb/script.go @@ -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 +} diff --git a/past-go/internal/upload/minio_integration_test.go b/past-go/internal/upload/minio_integration_test.go new file mode 100644 index 0000000..dd361cb --- /dev/null +++ b/past-go/internal/upload/minio_integration_test.go @@ -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") +} diff --git a/past-go/internal/upload/upload.go b/past-go/internal/upload/upload.go index 8af0357..4aff2db 100644 --- a/past-go/internal/upload/upload.go +++ b/past-go/internal/upload/upload.go @@ -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) {} diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go index 1ab4f01..ae65d68 100644 --- a/past-go/internal/wechat/subscribe_test.go +++ b/past-go/internal/wechat/subscribe_test.go @@ -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") } } diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index 89684fe..c6c6a8d 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -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() +} diff --git a/past-go/web/admin/src/App.tsx b/past-go/web/admin/src/App.tsx index 752fa72..5bfb024 100644 --- a/past-go/web/admin/src/App.tsx +++ b/past-go/web/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/past-go/web/admin/src/components/CoupleDetailDialog.tsx b/past-go/web/admin/src/components/CoupleDetailDialog.tsx index a10baeb..88bed0d 100644 --- a/past-go/web/admin/src/components/CoupleDetailDialog.tsx +++ b/past-go/web/admin/src/components/CoupleDetailDialog.tsx @@ -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(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) => ( ) @@ -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("adminActivityLog", { couple_id: coupleId }, "GET") }) if (q.isPending) return if (q.isError) return diff --git a/past-go/web/admin/src/components/Layout.tsx b/past-go/web/admin/src/components/Layout.tsx index 74acf78..70bf19d 100644 --- a/past-go/web/admin/src/components/Layout.tsx +++ b/past-go/web/admin/src/components/Layout.tsx @@ -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: "系统" }, diff --git a/past-go/web/admin/src/components/UserDialogs.tsx b/past-go/web/admin/src/components/UserDialogs.tsx index 07f3eda..7881a6a 100644 --- a/past-go/web/admin/src/components/UserDialogs.tsx +++ b/past-go/web/admin/src/components/UserDialogs.tsx @@ -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() { diff --git a/past-go/web/admin/src/lib/api.ts b/past-go/web/admin/src/lib/api.ts index 38d6ce7..bcee402 100644 --- a/past-go/web/admin/src/lib/api.ts +++ b/past-go/web/admin/src/lib/api.ts @@ -55,13 +55,7 @@ export async function apiUpload(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 { diff --git a/past-go/web/admin/src/lib/media.ts b/past-go/web/admin/src/lib/media.ts new file mode 100644 index 0000000..c086b0b --- /dev/null +++ b/past-go/web/admin/src/lib/media.ts @@ -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 } +} diff --git a/past-go/web/admin/src/pages/Backgrounds.tsx b/past-go/web/admin/src/pages/Backgrounds.tsx index 68f8f16..326ae8f 100644 --- a/past-go/web/admin/src/pages/Backgrounds.tsx +++ b/past-go/web/admin/src/pages/Backgrounds.tsx @@ -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("adminCoupleBgList", null, "GET") }) const fileRef = useRef(null) const [uploading, setUploading] = useState(false) - const [clearId, setClearId] = useState(null) + const [clearId, setClearId] = useState(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("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 (
背景管理 diff --git a/past-go/web/admin/src/pages/Couples.tsx b/past-go/web/admin/src/pages/Couples.tsx index 745c1d2..1e22448 100644 --- a/past-go/web/admin/src/pages/Couples.tsx +++ b/past-go/web/admin/src/pages/Couples.tsx @@ -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("adminCouples", { page, page_size: PAGE_SIZE }, "GET") }) - const [cid, setCid] = useState(null) + const [cid, setCid] = useState(null) const list = q.data?.items || [] return (
diff --git a/past-go/web/admin/src/pages/Files.tsx b/past-go/web/admin/src/pages/Files.tsx new file mode 100644 index 0000000..c3fa24e --- /dev/null +++ b/past-go/web/admin/src/pages/Files.tsx @@ -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>("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
+ 文件管理 +

查看所有用户上传的文件。相同内容仅保存一份,上传用户分别记录;点击缩略图可预览。

+ +
+
setDraft({ ...draft, keyword: e.target.value })} />
+
+
setDraft({ ...draft, start_date: e.target.value })} />
+
setDraft({ ...draft, end_date: e.target.value })} />
+ + + +
+ {q.isPending ? : q.isError ? : !q.data?.items.length ? : <> +
+ 预览文件上传用户大小 / 类型存储位置首次上传时间操作 + {q.data.items.map(file => + +
{file.original_name || `${file.id}.${file.suffix}`}
ID:{file.id}
MD5 与访问地址
MD5:{file.md5}
访问地址:{new URL(mediaUrl(file.id), window.location.origin).href}
对象地址:{file.url}
+ {file.uploaders.length ?
{file.uploaders.map(user =>
{user.nickname || "未设置昵称"}{user.deleted && 已注销}
ID {user.display_id || user.id}
)}
: 管理员上传}
+
{formatSize(file.size)}
{file.suffix.toUpperCase()}
{file.content_type}
+
Bucket:{file.bucket}
{file.object_key}
+ {fmtTime(file.created_at)} +
+
)}
+
+ + } +
+
+} diff --git a/past-go/web/admin/src/pages/Pets.tsx b/past-go/web/admin/src/pages/Pets.tsx index c740f04..5736254 100644 --- a/past-go/web/admin/src/pages/Pets.tsx +++ b/past-go/web/admin/src/pages/Pets.tsx @@ -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(null) + const [cid, setCid] = useState(null) const [page, setPage] = useState(1) const q = useQuery({ queryKey: ["admin_pets", breed, stage, page], diff --git a/past-go/web/admin/src/pages/Storage.tsx b/past-go/web/admin/src/pages/Storage.tsx index 1f903fd..bd16e57 100644 --- a/past-go/web/admin/src/pages/Storage.tsx +++ b/past-go/web/admin/src/pages/Storage.tsx @@ -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 diff --git a/past-go/web/admin/src/pages/SubscribeMessages.tsx b/past-go/web/admin/src/pages/SubscribeMessages.tsx index 7d800a2..22e302b 100644 --- a/past-go/web/admin/src/pages/SubscribeMessages.tsx +++ b/past-go/web/admin/src/pages/SubscribeMessages.tsx @@ -44,7 +44,7 @@ export default function SubscribeMessagesPage(){ {q.data.items.map(r=> {fmtTime(r.created_at)}
#{r.id}
{r.recipient_name||`用户#${r.to_user_id}`}{r.recipient_uid&&ID {r.recipient_uid}}
{r.to_openid||"无 OpenID"}
- {r.sender_name|| (Number(r.user_id)?`用户#${r.user_id}`:"系统")} + {r.sender_name|| (r.user_id?`用户#${r.user_id}`:"系统")} {EVENTS[r.action_type]||r.action_type}
{r.message_title}
{r.message_content}
{r.status==="success"?"发送成功":"发送失败"}{r.sent_at&&
{fmtTime(r.sent_at)}
}
{r.error_msg||"—"}{r.error_code!=null&&
微信错误码:{r.error_code}
}{r.http_status!=null&&
HTTP:{r.http_status}
}
diff --git a/past-go/web/admin/src/pages/Users.tsx b/past-go/web/admin/src/pages/Users.tsx index 0eac640..2fbc057 100644 --- a/past-go/web/admin/src/pages/Users.tsx +++ b/past-go/web/admin/src/pages/Users.tsx @@ -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(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(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], diff --git a/past-go/web/admin/tests/media.test.mjs b/past-go/web/admin/tests/media.test.mjs new file mode 100644 index 0000000..a7d2523 --- /dev/null +++ b/past-go/web/admin/tests/media.test.mjs @@ -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) +}) diff --git a/weapp/components/smoking-card/smoking-card.js b/weapp/components/smoking-card/smoking-card.js index a419416..68ac40f 100644 --- a/weapp/components/smoking-card/smoking-card.js +++ b/weapp/components/smoking-card/smoking-card.js @@ -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); diff --git a/weapp/config/env.js b/weapp/config/env.js new file mode 100644 index 0000000..41c0533 --- /dev/null +++ b/weapp/config/env.js @@ -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 }; diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 34cb9f7..3f6fc9e 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -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), diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js index 198316e..88531c4 100644 --- a/weapp/pages/checkin-detail/checkin-detail.js +++ b/weapp/pages/checkin-detail/checkin-detail.js @@ -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(); } diff --git a/weapp/pages/diary_detail/diary_detail.js b/weapp/pages/diary_detail/diary_detail.js index 281eda6..4373080 100644 --- a/weapp/pages/diary_detail/diary_detail.js +++ b/weapp/pages/diary_detail/diary_detail.js @@ -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 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; diff --git a/weapp/pages/timeline-detail/timeline-detail.js b/weapp/pages/timeline-detail/timeline-detail.js index 72fc8d9..ac73651 100644 --- a/weapp/pages/timeline-detail/timeline-detail.js +++ b/weapp/pages/timeline-detail/timeline-detail.js @@ -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 { diff --git a/weapp/pages/timeline/timeline.js b/weapp/pages/timeline/timeline.js index 77fc505..f567c53 100644 --- a/weapp/pages/timeline/timeline.js +++ b/weapp/pages/timeline/timeline.js @@ -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(); diff --git a/weapp/project.config.json b/weapp/project.config.json index 81ea1e2..18f5d10 100644 --- a/weapp/project.config.json +++ b/weapp/project.config.json @@ -1,6 +1,6 @@ { "compileType": "miniprogram", - "appid": "wxdc40d0c51cf8fe1e", + "appid": "wxb463820bf36dd5d6", "projectname": "couple-space", "description": "情侣空间小程序", "setting": { diff --git a/weapp/tests/env.test.cjs b/weapp/tests/env.test.cjs new file mode 100644 index 0000000..d40459a --- /dev/null +++ b/weapp/tests/env.test.cjs @@ -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'); + } +}); diff --git a/weapp/tests/file-media.test.cjs b/weapp/tests/file-media.test.cjs new file mode 100644 index 0000000..ea145c0 --- /dev/null +++ b/weapp/tests/file-media.test.cjs @@ -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); +}); diff --git a/weapp/tests/permissions.test.cjs b/weapp/tests/permissions.test.cjs index e6f9014..6534d8d 100644 --- a/weapp/tests/permissions.test.cjs +++ b/weapp/tests/permissions.test.cjs @@ -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()=>{ diff --git a/weapp/tests/smoking.test.cjs b/weapp/tests/smoking.test.cjs index 681881d..04da075 100644 --- a/weapp/tests/smoking.test.cjs +++ b/weapp/tests/smoking.test.cjs @@ -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()=>{ diff --git a/weapp/utils/file-media.js b/weapp/utils/file-media.js new file mode 100644 index 0000000..29c1bca --- /dev/null +++ b/weapp/utils/file-media.js @@ -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 }; diff --git a/weapp/utils/request.js b/weapp/utils/request.js index 38a6962..86a2267 100644 --- a/weapp/utils/request.js +++ b/weapp/utils/request.js @@ -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),