diff --git a/past-go/cmd/dbcheck/main.go b/past-go/cmd/dbcheck/main.go new file mode 100644 index 0000000..e8e44a5 --- /dev/null +++ b/past-go/cmd/dbcheck/main.go @@ -0,0 +1,125 @@ +// dbcheck runs only SELECT/SHOW 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" +) + +type result struct { + Name string `json:"name"` + Rows []map[string]any `json:"rows,omitempty"` + Error string `json:"error,omitempty"` +} + +func query(db *sql.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) + 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() + } + return r +} + +func run() error { + exe, err := os.Executable() + if err != nil { + return err + } + configPath := flag.String("config", filepath.Join(filepath.Dir(exe), "config.yaml"), "Existing server config.yaml; never upload its contents") + flag.Parse() + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + db, err := sql.Open("mysql", cfg.DSN()) + if err != nil { + return fmt.Errorf("cannot open database connection") + } + 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, "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'"), + } + 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"} { + 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")) + } + f, err := os.CreateTemp(filepath.Dir(exe), "dbcheck-report-*.json") + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + encodeErr := enc.Encode(report) + closeErr := f.Close() + if encodeErr != nil { + return encodeErr + } + if closeErr != nil { + return closeErr + } + fmt.Println("Read-only check complete. No migration or data changes performed.") + fmt.Println("Report:", f.Name()) + 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 e0e0366..c10c425 100644 --- a/past-go/cmd/server/main.go +++ b/past-go/cmd/server/main.go @@ -98,12 +98,22 @@ func serve(cfgPath string, stop <-chan struct{}) { 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) os.Exit(1) } + if err := db.MigrateSmokingRecords(); err != nil { + slog.Error("抽烟库存迁移失败", "err", err) + os.Exit(1) + } + if err := db.AdvanceUnbindings(); err != nil { + slog.Error("解绑状态处理失败", "err", err) + os.Exit(1) + } + go db.RunUnbindingWorker() wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db) app := &httpapi.App{ Cfg: cfg, diff --git a/past-go/deploy/README.md b/past-go/deploy/README.md index 0e1385b..77a17e6 100644 --- a/past-go/deploy/README.md +++ b/past-go/deploy/README.md @@ -1,3 +1,27 @@ +# 20261008 原权限兼容与抽烟功能 + +## 当前行为 + +- 日记、相册、记忆日、歌曲和普通打卡恢复原版按情侣成员作者查询;历史 couple_id 不再成为新增过滤条件。未绑定展示方式恢复原版。 +- 姨姨继续使用 period_records、period_daily_records、period_sex_records,恢复原版共享记录、来源选择和可见开关。启动不复制或切换姨姨表。此前创建的 personal_period_* 表保留,但本版不读写。 +- 照片响应保持原始 uploads/... 路径,不签名、不改成 /backend/uploads/...;上传静态文件恢复原访问方式,兼容正式版小程序。 +- 抽烟使用独立的 smoking_wallets 和 smoking_entries;消耗一支扣一支、买烟和别人递烟加库存、撤销操作保持事务和幂等。库存随账号保留。 +- 72 小时解绑冷静期继续保留:双方同意后开始计时,期间可取消,到期解除绑定且保留记录。其他数据权限按原版运行。 + +## 部署 + +1. 在宝塔备份当前数据库和 uploads,保存原可运行 exe;停止现有 Go 服务。 +2. 仅替换 past-server.exe,保留现有 config.yaml、data_dir、uploads、data 和数据库。无需重新配置 Nginx,已正常工作的 uploads 转发继续使用。 +3. 启动时补齐抽烟表和冷静期字段;仅迁移旧抽烟库存与流水。smoking_records_v1=done 后不会重复;已经完成 account_records_v1 的环境也不会重新导入库存。不存在姨姨数据迁移。 +4. 先验证正式版日记、相册、照片、记忆日、歌曲、普通打卡和姨姨记录,再用新版小程序验证抽烟。 +5. 新版小程序的姨姨页面已同步恢复共享方式;抽烟需要上传新版小程序才能显示。 + +Go 全套和前端回归在本机执行;尚未在生产数据库或微信真机运行本版。历史 binlog 和检查报告显示原记录在库,不应通过清表或倒放日志部署本版。 + +以下为 20261005 历史说明,当前部署以以上步骤为准。 + +--- + # 20261005 UI 精修与驼峰 API 更新 ## 部署顺序 diff --git a/past-go/deploy/diagnose-data-visibility.sql b/past-go/deploy/diagnose-data-visibility.sql new file mode 100644 index 0000000..4619de8 --- /dev/null +++ b/past-go/deploy/diagnose-data-visibility.sql @@ -0,0 +1,42 @@ +-- 只读排查:在原业务数据库中执行。不要修改关系、记录归属或迁移标记。 +SELECT DATABASE() AS current_database; + +SELECT 'users' AS table_name, COUNT(*) AS records FROM users +UNION ALL SELECT 'couples', COUNT(*) FROM couples +UNION ALL SELECT 'diaries', COUNT(*) FROM diaries +UNION ALL SELECT 'albums', COUNT(*) FROM albums +UNION ALL SELECT 'photos', COUNT(*) FROM photos +UNION ALL SELECT 'timeline', COUNT(*) FROM timeline +UNION ALL SELECT 'checkins', COUNT(*) FROM checkins +UNION ALL SELECT 'songs', COUNT(*) FROM songs +UNION ALL SELECT 'period_records', COUNT(*) FROM period_records; + +-- 关系一致性:只显示内部 ID,不输出手机号、密码、token 等信息。 +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 +LEFT JOIN users u1 ON u1.id = c.user1_id +LEFT JOIN users u2 ON u2.id = c.user2_id +ORDER BY c.id; + +-- 按历史空间检查记录是否还在,以及新的作者过滤是否排除了旧记录。 +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 +FROM diaries d LEFT JOIN couples c ON c.id=d.couple_id +GROUP BY d.couple_id,c.status +UNION ALL +SELECT 'albums', a.couple_id, c.status, COUNT(*), + SUM(CASE WHEN a.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) +FROM albums a LEFT JOIN couples c ON c.id=a.couple_id +GROUP BY a.couple_id,c.status +UNION ALL +SELECT 'timeline', t.couple_id, c.status, COUNT(*), + SUM(CASE WHEN t.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) +FROM timeline t LEFT JOIN couples c ON c.id=t.couple_id +GROUP BY t.couple_id,c.status +UNION ALL +SELECT 'checkins', k.couple_id, c.status, COUNT(*), + SUM(CASE WHEN k.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) +FROM checkins k LEFT JOIN couples c ON c.id=k.couple_id +GROUP BY k.couple_id,c.status; diff --git a/past-go/deploy/nginx-api.conf b/past-go/deploy/nginx-api.conf index bed3799..d8bc32c 100644 --- a/past-go/deploy/nginx-api.conf +++ b/past-go/deploy/nginx-api.conf @@ -26,3 +26,14 @@ location ^~ /api/ { # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-Proto $scheme; # } + +# 上传文件继续转发 Go;本版恢复原静态文件访问,不需要签名。 +location ^~ /backend/uploads/ { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "private, no-store" always; +} +# 避免不存在的旧路径被其他静态规则处理。 +location ^~ /uploads/ { return 403; } diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go index dde6f5b..89e7e2e 100644 --- a/past-go/internal/handler/admin.go +++ b/past-go/internal/handler/admin.go @@ -497,7 +497,7 @@ func adminDeleteUser(c *httpapi.Ctx) error { } var unbindStatusLabels = map[string]string{ - "pending": "待回应", "agreed": "已同意(已解绑)", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期", + "pending": "待对方回应", "cooling": "冷静期", "completed": "解绑完成", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期", } func adminUnbindRequests(c *httpapi.Ctx) error { @@ -506,20 +506,22 @@ func adminUnbindRequests(c *httpapi.Ctx) error { } rs, err := c.DB.Query(` SELECT - ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, + ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, ur.cooling_until, ur.completed_at, ur.cancelled_at, + cancel_user.nickname AS cancelled_by_name, r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar, c.couple_space_id, c.data_deleted, c.status as couple_status, u1.nickname as user1_name, u1.user_id as user1_uid, u2.nickname as user2_name, u2.user_id as user2_uid FROM unbind_requests ur JOIN users r ON ur.requester_id = r.id + LEFT JOIN users cancel_user ON ur.cancelled_by = cancel_user.id JOIN couples c ON ur.couple_id = c.id LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id WHERE NOT (c.status = 'active' AND ur.status = 'agreed') UNION SELECT - NULL as id, c.id as couple_id, 'agreed' as status, NULL as created_at, NULL as responded_at, + NULL as id, c.id as couple_id, 'completed' as status, NULL as created_at, NULL as responded_at, NULL as cooling_until, NULL as completed_at, NULL as cancelled_at, NULL as cancelled_by_name, NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar, c.couple_space_id, c.data_deleted, c.status as couple_status, u1.nickname as user1_name, u1.user_id as user1_uid, @@ -575,12 +577,17 @@ func adminForceUnbind(c *httpapi.Ctx) error { if couple == nil { return c.Err(404, "情侣不存在") } - if req, _ := c.DB.QueryOne("SELECT id, requester_id, responded_at FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); req == nil { - return c.Err(400, "双方尚未同意解绑,无法执行操作。请先让双方在微信小程序中完成解绑确认") + if err := c.DB.AdvanceUnbindings(); err != nil { + return err } - users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE couple_id = ?", cid) - softUnbind(c.DB, cid) - return c.OK(M{"success": true, "msg": "已执行解绑操作(双方已同意),情侣「" + coupleUserNames(users) + "」的关系已解除,所有数据已保留"}) + current, err := c.DB.QueryOne("SELECT status FROM couples WHERE id=?", cid) + if err != nil { + return err + } + if current.Str("status") != "unbound" { + return c.Err(409, "需双方同意并等待72小时冷静期结束,不能提前解绑") + } + return c.OK(M{"success": true, "msg": "冷静期已结束,系统已自动解绑并处理共同数据"}) } func adminRestoreCouple(c *httpapi.Ctx) error { @@ -595,15 +602,7 @@ func adminRestoreCouple(c *httpapi.Ctx) error { if couple == nil { return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复") } - u1, u2 := couple.Int("user1_id"), couple.Int("user2_id") - users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", u1, u2) - if len(users) < 2 { - return c.Err(400, "用户数据异常,无法恢复情侣关系") - } - c.DB.Exec("UPDATE couples SET status = 'active' WHERE id = ?", cid) - c.DB.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", cid, u1, u2) - c.DB.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid) - return c.OK(M{"success": true, "msg": "已恢复情侣关系「" + coupleUserNames(users) + "」,所有数据已恢复"}) + return c.Err(409, "旧空间已封存,恢复关系请由双方重新发起绑定,后台不能覆盖当前关系") } func adminDeleteCoupleData(c *httpapi.Ctx) error { @@ -618,6 +617,9 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error { if couple == nil { return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据") } + 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")) c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid) executeUnbind(c.DB, cid) diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go index 55e7ac8..1c4b73a 100644 --- a/past-go/internal/handler/album.go +++ b/past-go/internal/handler/album.go @@ -1,8 +1,11 @@ package handler import ( + "errors" "fmt" "strings" + "time" + "unicode/utf8" "past/internal/httpapi" "past/internal/store" @@ -19,6 +22,7 @@ func registerAlbum(r *httpapi.Router) { r.Handle("photo_upload", photoUpload) r.Handle("photo_delete", photoDelete) r.Handle("photo_add", photoAdd) + r.Handle("photo_update", photoUpdate) } // coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ? @@ -321,11 +325,7 @@ func photoAdd(c *httpapi.Ctx) error { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil { return err } - lat, lng := c.InFloat("lat"), c.InFloat("lng") - location := c.InTrim("location") - if lat != 0 && lng != 0 && location == "" { - location = c.App.Geo.ReverseGeocode(lat, lng) - } + location, lat, lng := photoLocation(c) path := c.In("path") id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid) @@ -344,3 +344,72 @@ func photoAdd(c *httpapi.Ctx) error { } return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path}) } + +// photoUpdate 仅修改照片信息,权限范围与照片列表保持一致。 +func photoUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + id := c.InInt("id") + if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") { + return c.Err(400, "参数不完整") + } + location, lat, lng := photoLocation(c) + takenAt, activity := c.InTrim("taken_at"), c.InTrim("activity") + if err := validatePhotoMetadata(takenAt, location, activity); err != nil { + return c.Err(400, err.Error()) + } + photo, err := c.DB.QueryOne("SELECT p.id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return err + } + if photo == nil { + return c.Err(404, "照片不存在或无权编辑") + } + 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]) + if err != nil { + return err + } + return c.OK(M{"success": true, "id": id, "taken_at": takenAt, "location": location, "activity": activity}) +} + +func validatePhotoMetadata(takenAt, location, activity string) error { + if takenAt != "" { + date, err := time.Parse("2006-01-02", takenAt) + if err != nil || date.Format("2006-01-02") != takenAt { + return errors.New("拍摄时间格式不正确") + } + } + if utf8.RuneCountInString(location) > 200 { + return errors.New("地点不能超过200字") + } + if utf8.RuneCountInString(activity) > 200 { + return errors.New("在做什么不能超过200字") + } + return nil +} + +// 用户填写的地址优先;留空时定位解析,最后使用当前用户保存的地址。 +func photoLocation(c *httpapi.Ctx) (string, float64, float64) { + location := c.InTrim("location") + lat, lng := c.InFloat("lat"), c.InFloat("lng") + if location != "" { + return location, lat, lng + } + if lat != 0 && lng != 0 { + location = c.App.Geo.ReverseGeocode(lat, lng) + } + if location == "" { + if user, err := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", c.UID()); err == nil && user != nil { + location = strings.TrimSpace(user.Str("location")) + } + } + return location, lat, lng +} diff --git a/past-go/internal/handler/album_test.go b/past-go/internal/handler/album_test.go new file mode 100644 index 0000000..f60c907 --- /dev/null +++ b/past-go/internal/handler/album_test.go @@ -0,0 +1,28 @@ +package handler + +import ( + "strings" + "testing" +) + +func TestValidatePhotoMetadata(t *testing.T) { + for _, tt := range []struct { + name, date, location, activity string + valid bool + }{ + {"clear fields", "", "", "", true}, + {"leap day", "2024-02-29", "北京颐和园", "第一次一起看日落", true}, + {"invalid day", "2026-02-29", "", "", false}, + {"date format", "2026-2-1", "", "", false}, + {"time not accepted", "2026-10-07 09:54", "", "", false}, + {"unicode at limit", "", strings.Repeat("景", 200), strings.Repeat("爱", 200), true}, + {"location too long", "", strings.Repeat("景", 201), "", false}, + {"story too long", "", "", strings.Repeat("爱", 201), false}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := validatePhotoMetadata(tt.date, tt.location, tt.activity); (got == nil) != tt.valid { + t.Fatalf("valid=%v, error=%v", tt.valid, got) + } + }) + } +} diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index 2ab44fd..ee08957 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -17,6 +17,8 @@ func registerCheckin(r *httpapi.Router) { r.Handle("checkin_stats", checkinStats) r.Handle("checkin_toggle", checkinToggle) r.Handle("checkin_delete", checkinDelete) + r.Handle("smoking_info", smokingInfo) + r.Handle("smoking_action", smokingAction) } // weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一 @@ -157,14 +159,14 @@ func checkinList(c *httpapi.Ctx) error { return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5}) } uid := c.UID() - items, err := c.DB.Query("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.created_by IN (?, ?) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1]) + items, err := c.DB.Query("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.created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = c.id) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1]) if err != nil { return err } for _, it := range items { decorateCheckin(it, uid, true) } - total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?)", ids[0], ids[1]) + total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = checkins.id)", ids[0], ids[1]) var logTotal int64 if cid := c.CoupleID(); cid != 0 { logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid) @@ -215,6 +217,9 @@ func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) { if item == nil { return nil, c.Err(404, "打卡项不存在") } + if err := guardSmokingCheckin(c, id); err != nil { + return nil, err + } return item, nil } @@ -374,15 +379,19 @@ func checkinDetail(c *httpapi.Ctx) error { func checkinStats(c *httpapi.Ctx) error { ids := coupleUserIds(c, 0) if len(ids) == 0 { - return c.OK([]any{}) + ids = []int64{c.UID(), 0} } td, ws := today(), weekMonday() + uid, partner := c.UID(), partnerUserId(c.DB, c.UID()) + smokeCount := func(owner int64, since string) int64 { + 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 my_checkin_time >= ?", ids[0], ids[1], ws), - "week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time >= ?", ids[0], ids[1], ws), - "week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) 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 my_checkin_time LIKE ?", ids[0], ids[1], td+"%"), - "today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%"), + "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), }) } @@ -395,6 +404,9 @@ func checkinToggle(c *httpapi.Ctx) error { return err } id := c.In("id") + if err := guardSmokingCheckin(c, c.InInt("id")); err != nil { + return err + } cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid) newStatus := int64(1) if cur != nil && store.Truthy(cur["is_completed"]) { @@ -428,6 +440,9 @@ func checkinDelete(c *httpapi.Ctx) error { return err } id := c.In("id") + if err := guardSmokingCheckin(c, c.InInt("id")); err != nil { + return err + } c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id) if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil { return err diff --git a/past-go/internal/handler/common.go b/past-go/internal/handler/common.go index e0cacec..0d0b895 100644 --- a/past-go/internal/handler/common.go +++ b/past-go/internal/handler/common.go @@ -144,13 +144,6 @@ func userOpenId(db store.Querier, uid int64) string { return row.Str("openid") } -// softUnbind 对应 softUnbind($db, $cid):仅标记解除,数据保留 -func softUnbind(db store.Querier, cid int64) { - db.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE couple_id = ? AND status IN ('pending','agreed')", cid) - db.Exec("UPDATE couples SET status = 'unbound', data_deleted = 0 WHERE id = ?", cid) - db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid) -} - // executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料 func executeUnbind(db store.Querier, cid int64) { db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid) diff --git a/past-go/internal/handler/couple.go b/past-go/internal/handler/couple.go index 2216145..aa14587 100644 --- a/past-go/internal/handler/couple.go +++ b/past-go/internal/handler/couple.go @@ -12,7 +12,7 @@ import ( "past/internal/store" ) -var targetIDRe = regexp.MustCompile(`^520\d{3,}$`) +var targetIDRe = regexp.MustCompile(`^[0-9]{6}$`) func registerCouple(r *httpapi.Router) { r.Handle("generate_invite_link", generateInviteLink) @@ -437,154 +437,159 @@ func userCoupleID(c *httpapi.Ctx, uid int64) int64 { return u.Int("couple_id") } +// withBoundCouple serializes all lifecycle transitions with the expiry worker. +func withBoundCouple(c *httpapi.Ctx, next func(*store.Tx, int64) error) error { + cid := c.CoupleID() + if cid == 0 { + return c.Err(400, "未绑定情侣") + } + tx, err := c.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + cp, err := tx.QueryOne("SELECT id,status FROM couples WHERE id=? FOR UPDATE", cid) + if err != nil { + return err + } + member, err := tx.QueryOne("SELECT id FROM users WHERE id=? AND couple_id=?", c.UID(), cid) + if err != nil { + return err + } + if cp == nil || cp.Str("status") != "active" || member == nil { + return c.Err(409, "情侣关系已变化,请刷新") + } + if err := next(tx, cid); err != nil { + return err + } + return tx.Commit() +} func unbindRequest(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - uid := c.UID() - cid := userCoupleID(c, uid) - if cid == 0 { - return c.Err(400, "未绑定情侣") - } - existing, _ := c.DB.QueryOne("SELECT id, status, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid) - if existing != nil { - if existing.Str("status") == "pending" { - return c.Err(400, "已有待处理的解绑请求,请等待对方回应") + 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 { + return err } - if existing.Str("status") == "agreed" { - softUnbind(c.DB, cid) - return c.OK(M{"success": true, "msg": "对方已同意解绑,情侣关系已解除,所有数据已保留"}) + existing, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status IN ('pending','cooling') FOR UPDATE", cid) + if err != nil { + return err } - return nil - } - if _, err := c.DB.Exec("INSERT INTO unbind_requests (couple_id, requester_id, status, created_at) VALUES (?, ?, 'pending', NOW())", cid, uid); err != nil { + if existing != nil { + return c.Err(409, "已有解绑申请或正在冷静期") + } + _, err = tx.Exec("INSERT INTO unbind_requests (couple_id,requester_id,status) VALUES (?,?,'pending')", cid, c.UID()) + return err + }) + if err != nil { return err } - logActivity(c.DB, cid, uid, "unbind_request", "发起了解除情侣绑定请求", "") - return c.OK(M{"success": true, "msg": "解绑请求已发送,等待对方确认(72小时内有效)"}) + return c.OK(M{"success": true, "msg": "请求已发送,对方同意后进入72小时冷静期"}) } - func unbindRespond(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - uid := c.UID() - // $input = json_decode(...) ?: $_POST agree := c.InBool("agree") - if len(c.Input()) == 0 { - agree = store.Truthy(c.Form("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()) + if err != nil { + return err + } + if req == nil { + 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")) + } else { + _, err = tx.Exec("UPDATE unbind_requests SET status='rejected',responded_at=NOW() WHERE id=?", req.Int("id")) + } + return err + }) + if err != nil { + return err } - cid := userCoupleID(c, uid) - if cid == 0 { - return c.Err(400, "未绑定情侣") - } - req, _ := c.DB.QueryOne("SELECT id, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status = 'pending' AND requester_id != ? ORDER BY id DESC LIMIT 1", cid, uid) - if req == nil { - return c.Err(400, "没有待处理的解绑请求") - } - if created, err := parseDate(req.Str("created_at")); err == nil && time.Since(created) > 72*time.Hour { - c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id")) - return c.Err(400, "解绑请求已超过72小时,已自动取消") - } - if agree { - c.DB.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE id = ?", req.Int("id")) - logActivity(c.DB, cid, uid, "unbind_agreed", "同意了解除情侣绑定请求", "") - softUnbind(c.DB, cid) - return c.OK(M{"success": true, "msg": "已同意解绑,情侣关系已解除,所有数据已保留"}) - } - c.DB.Exec("UPDATE unbind_requests SET status = 'rejected', responded_at = NOW() WHERE id = ?", req.Int("id")) - logActivity(c.DB, cid, uid, "unbind_rejected", "拒绝了解除情侣绑定请求", "") - return c.OK(M{"success": true, "msg": "已拒绝解绑请求"}) + return c.OK(M{"success": true, "msg": "申请已处理,双方同意后进入72小时冷静期"}) } - func unbindCancel(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - uid := c.UID() - cid := userCoupleID(c, uid) - if cid == 0 { - return c.Err(400, "未绑定情侣") - } - req, _ := c.DB.QueryOne("SELECT id, status FROM unbind_requests WHERE couple_id = ? AND requester_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid, uid) - if req == nil { - return c.Err(400, "没有可撤销的解绑请求") - } - c.DB.Exec("UPDATE unbind_requests SET status = 'cancelled', responded_at = NOW() WHERE id = ?", req.Int("id")) - logActivity(c.DB, cid, uid, "unbind_cancelled", "撤销了解除情侣绑定请求", "") - return c.OK(M{"success": true, "msg": "已撤销解绑请求"}) -} - -func unbindStatus(c *httpapi.Ctx) error { - uid := c.UID() - // $cid 来源不同类型不同:来自 users 行为字符串;来自 unbind_requests 反查时经过 intval 为整数 - var cidVal any - u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) - cid := int64(0) - if u != nil && u.Int("couple_id") != 0 { - cid, cidVal = u.Int("couple_id"), u["couple_id"] - } - if cid == 0 { - found, _ := c.DB.QueryOne("SELECT ur.couple_id FROM unbind_requests ur WHERE ur.requester_id = ? OR ur.id IN (SELECT ur2.id FROM unbind_requests ur2 JOIN couples c ON ur2.couple_id = c.id WHERE (c.user1_id = ? OR c.user2_id = ?)) ORDER BY ur.id DESC LIMIT 1", uid, uid, uid) - if found != nil { - cid, cidVal = found.Int("couple_id"), found.Int("couple_id") + 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()) + if err != nil { + return err } - } - if cid == 0 { - return c.OK(M{"status": "none", "msg": "未绑定情侣"}) - } - req, err := c.DB.QueryOne("SELECT id, requester_id, status, created_at, responded_at FROM unbind_requests WHERE couple_id = ? ORDER BY id DESC LIMIT 1", cid) + 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")) + return err + }) if err != nil { return err } - dataDeleted := int64(0) - if cd, _ := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid); cd != nil { - dataDeleted = cd.Int("data_deleted") + return c.OK(M{"success": true, "msg": "已取消解绑,关系继续保留"}) +} +func unbindStatus(c *httpapi.Ctx) error { + if err := c.DB.AdvanceUnbindings(); err != nil { + return err + } + 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 err != nil { + return err + } + if cp != nil { + cid = cp.Int("id") + } + } + if cid == 0 { + 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 { + 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) + if err != nil { + return err } if req == nil { - return c.OK(M{"status": "none", "msg": "无解绑请求", "couple_id": cidVal, "data_deleted": dataDeleted}) - } - if req.Str("status") == "pending" { - created, _ := parseDate(req.Str("created_at")) - elapsed := int64(time.Since(created).Seconds()) - remaining := int64(72*3600) - elapsed - if remaining < 0 { - remaining = 0 - } - if elapsed > 72*3600 { - c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id")) - req["status"] = "expired" - } else { - req["remaining_seconds"] = remaining - } + return c.OK(M{"status": "none", "couple_id": cid}) } req["is_requester"] = req.Int("requester_id") == uid - requester, _ := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", req.Int("requester_id")) - name := "未知" - if requester != nil { - name = requester.Str("nickname") - if name == "" { - name = requester.Str("username") + req["couple_id"] = cid + req["data_deleted"] = 0 + if req.Str("status") == "completed" { + cp, err := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid) + if err != nil { + return err + } + if cp != nil { + req["data_deleted"] = cp.Int("data_deleted") } } - req["requester_name"] = name - req["couple_id"] = cid - req["data_deleted"] = dataDeleted + owner, err := c.DB.QueryOne("SELECT nickname FROM users WHERE id=?", req.Int("requester_id")) + if err != nil { + return err + } + if owner != nil { + req["requester_name"] = owner.Str("nickname") + } return c.OK(req) } - func unbindCouple(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - uid := c.UID() - cid := userCoupleID(c, uid) - if cid == 0 { - return c.Err(400, "未绑定情侣") + if err := c.DB.AdvanceUnbindings(); err != nil { + return err } - if ag, _ := c.DB.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); ag == nil { - return c.Err(400, "没有已确认的解绑请求,请先发起解绑") + if c.CoupleID() != 0 { + return c.Err(409, "冷静期结束后将自动解绑,不能提前解除") } - softUnbind(c.DB, cid) - return c.OK(M{"success": true, "msg": "已解除绑定"}) + return c.OK(M{"success": true}) } diff --git a/past-go/internal/handler/couple_id_test.go b/past-go/internal/handler/couple_id_test.go new file mode 100644 index 0000000..ef841a4 --- /dev/null +++ b/past-go/internal/handler/couple_id_test.go @@ -0,0 +1,16 @@ +package handler + +import "testing" + +func TestInvitationUserIDFormat(t *testing.T) { + for _, id := range []string{"520087", "521685", "523019", "999999"} { + if !targetIDRe.MatchString(id) { + t.Errorf("valid six-digit ID rejected: %s", id) + } + } + for _, id := range []string{"", "52301", "5230190", "52301a", "523019 ", "523019"} { + if targetIDRe.MatchString(id) { + t.Errorf("invalid ID accepted: %q", id) + } + } +} diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go index 0218b55..f8f8d78 100644 --- a/past-go/internal/handler/misc.go +++ b/past-go/internal/handler/misc.go @@ -38,6 +38,9 @@ func uploadGeneric(c *httpapi.Ctx) error { if c.FormHas("sub_dir") { subDir = c.Form("sub_dir") } + if strings.ContainsAny(subDir, "/\\.") || subDir == "" { + return c.Err(400, "无效的上传目录") + } uid := c.UID() if uid == 0 { return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) diff --git a/past-go/internal/handler/permissions_test.go b/past-go/internal/handler/permissions_test.go new file mode 100644 index 0000000..c464182 --- /dev/null +++ b/past-go/internal/handler/permissions_test.go @@ -0,0 +1,128 @@ +package handler + +import ( + "database/sql/driver" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "past/internal/config" + "past/internal/httpapi" + "past/internal/store" + "past/internal/testdb" +) + +func permissionCtx(t *testing.T, method, uri, body string, steps []testdb.Step) (*httpapi.Ctx, *httptest.ResponseRecorder, *testdb.Script) { + t.Helper() + db, s := testdb.Open(t, steps) + out := httptest.NewRecorder() + g, _ := gin.CreateTestContext(out) + 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{}} + 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 { + return testdb.Step{Match: "FROM user_tokens", Columns: []string{"user_id"}, Rows: [][]driver.Value{{uid}}} +} +func cidStep(cid int64) testdb.Step { + return testdb.Step{Match: "SELECT couple_id FROM users WHERE id = ?", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{cid}}} +} +func TestAgreeStartsCoolingWithoutUnbinding(t *testing.T) { + c, out, s := permissionCtx(t, "POST", "/api/unbindRespond", `{"agree":true}`, []testdb.Step{ + uidStep(2), cidStep(10), + {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)}}, + }) + unbindRespond(c) + if out.Code != 200 || s.Commits != 1 { + t.Fatalf("code=%d commits=%d body=%s", out.Code, s.Commits, out.Body.String()) + } +} +func TestEitherPartnerCanCancelCoolingButNotAfterDeadline(t *testing.T) { + for _, expired := range []bool{false, true} { + t.Run(map[bool]string{false: "partner cancels", true: "deadline reached"}[expired], func(t *testing.T) { + rows := [][]driver.Value{{int64(8)}} + if expired { + rows = nil + } + steps := []testdb.Step{uidStep(2), cidStep(10), {Match: "FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}}, {Match: "FROM users WHERE id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}}, {Match: "status='cooling' AND cooling_until>NOW()", Columns: []string{"id"}, Rows: rows}} + if !expired { + steps = append(steps, testdb.Step{Match: "status='cancelled'"}) + } + + c, out, s := permissionCtx(t, "POST", "/api/unbindCancel", `{}`, steps) + unbindCancel(c) + want := 200 + if expired { + want = 409 + if s.Commits != 0 { + t.Fatal("expired cancellation committed") + } + } + if out.Code != want { + t.Fatalf("code=%d body=%s", out.Code, out.Body.String()) + } + }) + } +} +func TestLegacyDetailsStillRequireCurrentMemberAuthors(t *testing.T) { + for _, tt := range []struct { + name, predicate string + handler httpapi.HandlerFunc + }{ + {"diary", "d.created_by IN", diaryDetail}, + {"timeline", "WHERE id = ? AND created_by IN", timelineDetail}, + {"checkin", "c.created_by IN", checkinDetail}, + } { + t.Run(tt.name, func(t *testing.T) { + c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9", "", []testdb.Step{uidStep(1), cidStep(22), {Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}}, {Match: tt.predicate, Columns: []string{"id"}}}) + tt.handler(c) + if out.Code != 404 { + t.Fatalf("missing or unrelated detail returned %d: %s", out.Code, out.Body.String()) + } + }) + } +} + +func TestLegacyMemberRecordsRemainReadableAcrossHistoricalSpaceIDs(t *testing.T) { + for _, tt := range []struct { + name, predicate string + handler httpapi.HandlerFunc + }{ + {"timeline", "FROM timeline WHERE id = ? AND created_by IN", timelineDetail}, + {"photos", "WHERE a.created_by IN (?, ?) AND p.album_id = ?", photos}, + } { + t.Run(tt.name, func(t *testing.T) { + c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9&album_id=9", "", []testdb.Step{ + uidStep(1), cidStep(22), + {Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}}, + {Match: tt.predicate, Columns: []string{"id", "couple_id", "created_by", "path"}, Rows: [][]driver.Value{{int64(9), int64(10), int64(1), "uploads/users/1/photos/a.jpg"}}}, + }) + tt.handler(c) + if out.Code != 200 || !strings.Contains(out.Body.String(), "uploads/users/1/photos/a.jpg") { + t.Fatalf("legacy record disappeared: code=%d body=%s", out.Code, out.Body.String()) + } + }) + } +} + +func TestLegacyPeriodReadsSharedTablesInsteadOfPersonalTables(t *testing.T) { + c, out, _ := permissionCtx(t, "GET", "/api/periodList", "", []testdb.Step{ + uidStep(1), cidStep(10), + {Match: "SELECT period_visible FROM users", Columns: []string{"period_visible"}, Rows: [][]driver.Value{{int64(1)}}}, + {Match: "SELECT id, period_visible FROM users WHERE couple_id", Columns: []string{"id", "period_visible"}, Rows: [][]driver.Value{{int64(2), int64(1)}}}, + {Match: "SELECT user1_id, user2_id, period_source_user_id", Columns: []string{"user1_id", "user2_id", "period_source_user_id"}, Rows: [][]driver.Value{{int64(1), int64(2), int64(2)}}}, + {Match: "FROM period_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id", "user_id"}, Rows: [][]driver.Value{{int64(7), int64(2)}}}, + {Match: "FROM period_daily_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}}, + {Match: "FROM period_sex_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}}, + }) + periodList(c) + if out.Code != 200 || !strings.Contains(out.Body.String(), `"user_id":"2"`) { + t.Fatalf("shared period missing: %d %s", out.Code, out.Body.String()) + } +} diff --git a/past-go/internal/handler/smoking.go b/past-go/internal/handler/smoking.go new file mode 100644 index 0000000..2303e5d --- /dev/null +++ b/past-go/internal/handler/smoking.go @@ -0,0 +1,232 @@ +package handler + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "past/internal/httpapi" +) + +func guardSmokingCheckin(c *httpapi.Ctx, id int64) error { + row, err := c.DB.QueryOne("SELECT id FROM smoking_accounts WHERE checkin_id = ?", id) + if err != nil { + return err + } + if row != nil { + return c.Err(400, "请通过抽烟记录操作库存和撤销") + } + return nil +} + +func smokingDelta(kind string, quantity, stock int64) (int64, error) { + if quantity < 0 || quantity > 1000000 { + return 0, errors.New("数量超出范围") + } + switch kind { + case "consume", "give": + if quantity != 1 { + return 0, errors.New("每次操作一支") + } + if stock < 1 { + return 0, errors.New("库存不足,请先补充库存") + } + return -1, nil + case "receive_smoke": + if quantity != 1 { + return 0, errors.New("每次记录一支") + } + return 0, nil + case "purchase", "receive": + if quantity < 1 || quantity > 10000 { + return 0, errors.New("补充数量应为1到10000支") + } + return quantity, nil + case "adjust": + return quantity - stock, nil + default: + return 0, errors.New("不支持的操作") + } +} + +func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) { + result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "logs": []any{}, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true} + owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid) + if err != nil { + return nil, err + } + if owner != nil { + result["owner_name"] = owner.Str("nickname") + if owner.Str("nickname") == "" { + result["owner_name"] = owner.Str("username") + } + } + partner := partnerUserId(c.DB, c.UID()) + result["partner_id"] = partner + account, err := c.DB.QueryOne("SELECT user_id AS id, stock FROM smoking_wallets WHERE user_id = ?", uid) + if err != nil { + return nil, err + } + if account == nil { + return result, nil + } + aid := account.Int("id") + result["stock"] = account.Int("stock") + summary, err := c.DB.QueryOne("SELECT COUNT(*) AS total_count, COALESCE(SUM(created_at >= CURRENT_DATE),0) AS today_count, MAX(created_at) AS last_time FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0", aid) + if err != nil { + return nil, err + } + if summary != nil { + result["today_count"] = summary.Int("today_count") + result["total_count"] = summary.Int("total_count") + result["last_time"] = summary.Str("last_time") + } + 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 50", aid) + if err != nil { + return nil, err + } + result["logs"] = rows(logs) + 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) + if err != nil { + return nil, err + } + result["daily"] = rows(daily) + return result, nil +} + +func smokingInfo(c *httpapi.Ctx) error { + uid := c.UID() + requested := c.QInt("user_id") + if requested == 0 { + requested = uid + } + if requested != uid && requested != partnerUserId(c.DB, uid) { + return c.Err(403, "只能查看自己和当前伴侣的记录") + } + info, err := smokingSnapshot(c, requested) + if err != nil { + return err + } + return c.OK(info) +} + +func smokingAction(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + var err error + uid := c.UID() + kind, key, note := c.InTrim("kind"), c.InTrim("request_key"), c.InTrim("note") + quantity, quantityErr := strconv.ParseInt(c.InTrim("quantity"), 10, 64) + optionalInt := func(key string) (int64, error) { + value := c.InTrim(key) + if value == "" { + return 0, nil + } + 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, "数量、金额和记录标识必须是有效整数") + } + if key == "" || len(key) > 64 || utf8.RuneCountInString(note) > 200 || amount < 0 || amount > 100000000 { + return c.Err(400, "参数不正确") + } + if kind != "undo" { + if _, err := smokingDelta(kind, quantity, 1000000); err != nil { + return c.Err(400, err.Error()) + } + } + if note != "" { + if err := checkTextOr400(c, note); err != nil { + return err + } + } + payload, _ := json.Marshal([]any{kind, quantity, amount, originalID, note}) + hash := fmt.Sprintf("%x", sha256.Sum256(payload)) + tx, err := c.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec("INSERT IGNORE INTO smoking_wallets (user_id) VALUES (?)", uid); err != nil { + return err + } + account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid) + if err != nil { + return err + } + if account == nil { + return errors.New("库存账户创建失败") + } + aid, stock := account.Int("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 + } + if previous != nil { + if previous.Str("payload_hash") != hash { + return c.Err(409, "请求标识已被其他操作使用") + } + if err = tx.Commit(); err != nil { + return err + } + info, err := smokingSnapshot(c, uid) + if err != nil { + return err + } + return c.OK(M{"success": true, "info": info}) + } + var delta int64 + if kind == "undo" { + original, err := tx.QueryOne("SELECT id,delta,kind,undone FROM smoking_entries WHERE user_id = ? AND id = ? FOR UPDATE", aid, originalID) + if err != nil { + return err + } + if original == nil || original.Int("undone") != 0 || original.Str("kind") == "undo" { + return c.Err(409, "这条记录无法撤销或已经撤销") + } + delta = -original.Int("delta") + quantity = 0 + if stock+delta < 0 { + return c.Err(409, "库存已使用,无法撤销这次补充,请通过调整库存修正") + } + if _, err = tx.Exec("UPDATE smoking_entries SET undone = 1 WHERE id = ?", originalID); err != nil { + return err + } + + } else { + delta, err = smokingDelta(kind, quantity, stock) + if err != nil { + return c.Err(400, err.Error()) + } + } + if stock+delta > 1000000 && delta > 0 { + return c.Err(400, "库存不能超过100万支") + } + if _, err = tx.Exec("UPDATE smoking_wallets SET stock = ? WHERE user_id = ?", stock+delta, aid); err != nil { + return err + } + if _, err = tx.Insert("INSERT INTO smoking_entries (user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id) VALUES (?,?,?,?,?,?,?,?,?,?)", aid, kind, quantity, delta, stock+delta, amount, strings.TrimSpace(note), key, hash, nullableID(originalID)); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + info, err := smokingSnapshot(c, uid) + if err != nil { + return err + } + return c.OK(M{"success": true, "info": info}) +} +func nullableID(id int64) any { + if id == 0 { + return nil + } + return id +} diff --git a/past-go/internal/handler/smoking_test.go b/past-go/internal/handler/smoking_test.go new file mode 100644 index 0000000..25fcb71 --- /dev/null +++ b/past-go/internal/handler/smoking_test.go @@ -0,0 +1,38 @@ +package handler + +import "testing" + +func TestSmokingDelta(t *testing.T) { + for _, tt := range []struct { + name, kind string + quantity, stock, want int64 + valid bool + }{ + {"give one", "give", 1, 20, -1, true}, + {"cannot give from empty stock", "give", 1, 0, 0, false}, + {"give exactly one", "give", 2, 20, 0, false}, + {"consume one", "consume", 1, 20, -1, true}, + {"empty stock", "consume", 1, 0, 0, false}, + {"no bulk smoking", "consume", 2, 20, 0, false}, + {"purchase pack", "purchase", 20, 0, 20, true}, + {"received one", "receive", 1, 3, 1, true}, + {"received and immediately consumed", "receive_smoke", 1, 0, 0, true}, + {"initial stock", "adjust", 17, 0, 17, true}, + {"inventory correction", "adjust", 3, 17, -14, true}, + {"clear inventory", "adjust", 0, 17, -17, true}, + {"negative quantity", "receive", -1, 0, 0, false}, + {"zero purchase", "purchase", 0, 0, 0, false}, + {"too much purchase", "purchase", 10001, 0, 0, false}, + {"unknown operation", "other", 1, 0, 0, false}, + } { + t.Run(tt.name, func(t *testing.T) { + delta, err := smokingDelta(tt.kind, tt.quantity, tt.stock) + if (err == nil) != tt.valid || delta != tt.want { + t.Fatalf("delta=%d err=%v", delta, err) + } + if err == nil && tt.stock+delta < 0 { + t.Fatal("negative inventory") + } + }) + } +} diff --git a/past-go/internal/handler/user.go b/past-go/internal/handler/user.go index 5a85bcf..c1d3186 100644 --- a/past-go/internal/handler/user.go +++ b/past-go/internal/handler/user.go @@ -321,6 +321,10 @@ func wechatLogin(c *httpapi.Ctx) error { } func checkAuth(c *httpapi.Ctx) error { + if err := c.DB.AdvanceUnbindings(); err != nil { + return err + } + c.CoupleID() uid := c.UID() if uid == 0 { return c.OK(M{"logged_in": false}) diff --git a/past-go/internal/httpapi/auth.go b/past-go/internal/httpapi/auth.go index e1d8453..2606aa6 100644 --- a/past-go/internal/httpapi/auth.go +++ b/past-go/internal/httpapi/auth.go @@ -45,6 +45,10 @@ func (c *Ctx) UID() int64 { // CoupleID 对应 getCoupleId();无则返回 0 func (c *Ctx) CoupleID() int64 { + if c.coupleResolved { + return c.coupleID + } + c.coupleResolved = true uid := c.UID() if uid == 0 { return 0 @@ -53,7 +57,8 @@ func (c *Ctx) CoupleID() int64 { if row == nil { return 0 } - return row.Int("couple_id") + c.coupleID = row.Int("couple_id") + return c.coupleID } // NewToken 生成 32 位 hex token(与 PHP session_id 格式一致) @@ -77,6 +82,9 @@ func (c *Ctx) requireUserLogin() error { if uid == 0 { return c.Err(401, "请先登录") } + if err := c.DB.AdvanceUnbindings(); err != nil { + return err + } user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid) if err != nil { return err @@ -180,3 +188,5 @@ func (c *Ctx) requireAdminLogin() error { } return nil } + +func (c *Ctx) SetCoupleID(cid int64) { c.coupleID = cid; c.coupleResolved = true } diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go index 2bbb693..b0b8f84 100644 --- a/past-go/internal/httpapi/ctx.go +++ b/past-go/internal/httpapi/ctx.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "mime/multipart" "net/http" @@ -38,13 +39,15 @@ type Ctx struct { CamelCase bool Method string - input map[string]any - inputParsed bool - body []byte - bodyRead bool - uid int64 - uidResolved bool - responded bool + input map[string]any + inputParsed bool + body []byte + bodyRead bool + uid int64 + uidResolved bool + coupleID int64 + coupleResolved bool + responded bool } // errResponded 表示响应已写出(对应 PHP 的 exit)。 @@ -62,6 +65,10 @@ func (c *Ctx) JSON(code int, v any) error { return errResponded } c.responded = true + if c.uidResolved && c.uid != 0 && c.coupleResolved { + c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID)) + c.G.Header("X-User-ID", fmt.Sprint(c.uid)) + } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) @@ -76,8 +83,11 @@ func (c *Ctx) JSON(code int, v any) error { if err := dec.Decode(&payload); err != nil { return err } + if c.CamelCase { + payload = renameKeys(payload, camelName) + } buf.Reset() - if err := enc.Encode(renameKeys(payload, camelName)); err != nil { + if err := enc.Encode(payload); err != nil { return err } } @@ -180,7 +190,7 @@ func normalizeNumbers(v any) any { } // In 对应 $input['key'] ?? nil -func (c *Ctx) In(key string) any { return c.Input()[key] } +func (c *Ctx) In(key string) any { return normalizeMediaInput(c.Input()[key]) } // InHas 对应 isset($input['key']) func (c *Ctx) InHas(key string) bool { diff --git a/past-go/internal/httpapi/media.go b/past-go/internal/httpapi/media.go new file mode 100644 index 0000000..52925a8 --- /dev/null +++ b/past-go/internal/httpapi/media.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "encoding/json" + "net/url" + "strings" +) + +func normalizeMediaPath(value string) string { + parsed, err := url.Parse(value) + if err != nil { + return value + } + + if strings.HasPrefix(parsed.Path, "/backend/uploads/") { + return strings.TrimPrefix(parsed.Path, "/backend/") + } + if !parsed.IsAbs() && parsed.Host == "" && strings.HasPrefix(parsed.Path, "uploads/") { + return parsed.Path + } + return value +} + +func normalizeMediaInput(value any) any { + switch v := value.(type) { + case map[string]any: + for k, item := range v { + v[k] = normalizeMediaInput(item) + } + return v + case []any: + for i, item := range v { + v[i] = normalizeMediaInput(item) + } + return v + case string: + if strings.HasPrefix(v, "[") { + var list []any + if json.Unmarshal([]byte(v), &list) == nil { + data, _ := json.Marshal(normalizeMediaInput(list)) + return string(data) + } + } + return normalizeMediaPath(v) + } + return value +} diff --git a/past-go/internal/httpapi/media_test.go b/past-go/internal/httpapi/media_test.go new file mode 100644 index 0000000..a2579c7 --- /dev/null +++ b/past-go/internal/httpapi/media_test.go @@ -0,0 +1,48 @@ +package httpapi + +import ( + "github.com/gin-gonic/gin" + "net/http/httptest" + "net/url" + "os" + "past/internal/config" + "path/filepath" + "strings" + "testing" +) + +func TestLegacyPhotoResponsesKeepTheirOriginalPaths(t *testing.T) { + out := httptest.NewRecorder() + g, _ := gin.CreateTestContext(out) + g.Request = httptest.NewRequest("GET", "/api/photos", nil) + c := &Ctx{G: g, R: g.Request, CamelCase: true} + c.OK(M{"path": "uploads/users/1/photos/a.jpg"}) + if !strings.Contains(out.Body.String(), `"path":"uploads/users/1/photos/a.jpg"`) { + t.Fatal(out.Body.String()) + } +} +func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) { + dir := t.TempDir() + folder := filepath.Join(dir, "uploads", "users", "1") + if err := os.MkdirAll(folder, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, "a.jpg"), []byte("photo"), 0600); err != nil { + t.Fatal(err) + } + router := NewRouter(&App{Cfg: &config.Config{DataDir: dir}}, nil).Engine(false) + out := httptest.NewRecorder() + router.ServeHTTP(out, httptest.NewRequest("GET", "/backend/uploads/users/1/a.jpg", nil)) + if out.Code != 200 || out.Body.String() != "photo" { + t.Fatalf("code=%d body=%s", out.Code, out.Body.String()) + } +} +func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) { + resource := "uploads/users/1/diaries/a.jpg" + signed := "https://past.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode() + input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"} + normalized := normalizeMediaInput(input).(map[string]any) + if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" { + t.Fatalf("wrong normalization: %v", normalized) + } +} diff --git a/past-go/internal/store/db.go b/past-go/internal/store/db.go index 91fb13f..9c0428b 100644 --- a/past-go/internal/store/db.go +++ b/past-go/internal/store/db.go @@ -18,6 +18,7 @@ type DB struct { // NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为: // 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。 NumericAsString bool + UploadDir string // 用于解绑完成后的持久化附件清理队列 } // Row 一行查询结果,键为列名(或别名)。 diff --git a/past-go/internal/store/relationships.go b/past-go/internal/store/relationships.go new file mode 100644 index 0000000..afe6bee --- /dev/null +++ b/past-go/internal/store/relationships.go @@ -0,0 +1,139 @@ +package store + +import ( + "crypto/sha256" + "fmt" + "log/slog" + "strings" + "time" +) + +// AdvanceUnbindings runs on startup, in the worker, and before authenticated access. +// Expiry is decided with database time under the same row locks as cancellation. +func (db *DB) AdvanceUnbindings() error { + pending, err := db.Query("SELECT DISTINCT couple_id FROM unbind_requests WHERE status = 'cooling' AND cooling_until <= NOW()") + if err != nil { + return err + } + for _, row := range pending { + if err := db.finishUnbinding(row.Int("couple_id")); err != nil { + return err + } + } + return nil +} +func (db *DB) finishUnbinding(cid int64) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + cp, err := tx.QueryOne("SELECT id, couple_space_id FROM couples WHERE id = ? FOR UPDATE", cid) + if err != nil || cp == nil { + return err + } + req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'cooling' AND cooling_until <= NOW() FOR UPDATE", cid) + 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_") { + args = append(args, cid) + } + if _, err = tx.Exec(stmt, args...); err != nil { + return err + } + } + for _, stmt := range []string{ + "UPDATE users SET couple_id = NULL, updated_at = NOW() WHERE couple_id = ?", + "UPDATE couples SET status = 'unbound', data_deleted = 1 WHERE id = ?", + "UPDATE unbind_requests SET status = 'completed', completed_at = NOW() WHERE couple_id = ? AND status = 'cooling'", + } { + if _, err = tx.Exec(stmt, cid); err != nil { + return err + } + } + return tx.Commit() +} +func (db *DB) RunUnbindingWorker() { + run := func() { + if err := db.AdvanceUnbindings(); err != nil { + slog.Error("解绑到期处理失败", "err", err) + } + if err := db.cleanUnboundFiles(); err != nil { + slog.Error("解绑附件清理失败,将重试", "err", err) + } + } + run() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + run() + } +} + +// MigrateSmokingRecords copies only legacy smoking inventory; shared journal tables stay unchanged. +func (db *DB) MigrateSmokingRecords() error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec("INSERT IGNORE INTO settings (`key`,`value`) VALUES ('smoking_records_v1','pending')"); err != nil { + return err + } + 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'") + 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 { + return err + } + return tx.Commit() + } + accounts, err := tx.Query("SELECT user_id,SUM(stock) AS stock FROM smoking_accounts GROUP BY user_id") + if err != nil { + return err + } + for _, row := range accounts { + if _, err = tx.Exec("INSERT INTO smoking_wallets (user_id,stock) VALUES (?,?)", row["user_id"], row["stock"]); err != nil { + 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") + if err != nil { + return err + } + latestKeys := map[string]int64{} + for _, row := range logs { + latestKeys[fmt.Sprint(row.Int("user_id"), ":", row.Str("request_key"))] = row.Int("id") + } + balances := map[int64]int64{} + for _, row := range logs { + uid := row.Int("user_id") + balances[uid] += row.Int("delta") + key := row.Str("request_key") + if latestKeys[fmt.Sprint(uid, ":", key)] != row.Int("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 { + return err + } + return tx.Commit() +} diff --git a/past-go/internal/store/relationships_test.go b/past-go/internal/store/relationships_test.go new file mode 100644 index 0000000..8bedaf0 --- /dev/null +++ b/past-go/internal/store/relationships_test.go @@ -0,0 +1,137 @@ +package store + +import ( + "database/sql/driver" + "errors" + "fmt" + "past/internal/testdb" + "strings" + "testing" +) + +func TestCoolingCompletionRechecksUnderLock(t *testing.T) { + for _, cancelled := range []bool{false, true} { + t.Run(map[bool]string{true: "cancelled before lock", false: "deadline reached"}[cancelled], func(t *testing.T) { + reqRows := [][]driver.Value{{int64(8)}} + if cancelled { + reqRows = nil + } + steps := []testdb.Step{ + {Match: "SELECT DISTINCT couple_id", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}}, + {Match: "FROM couples WHERE id = ? FOR UPDATE", Args: []any{int64(7)}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}}, + {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_") { + args = append(args, int64(7)) + } + steps = append(steps, testdb.Step{Match: stmt, Args: args}) + } + 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} + if err := db.AdvanceUnbindings(); err != nil { + t.Fatal(err) + } + if cancelled && s.Commits != 0 { + t.Fatal("cancelled relationship committed unbinding") + } + if !cancelled && s.Commits != 1 { + t.Fatal("completed unbinding did not commit") + } + }) + } +} +func TestCoolingFailureRollsBackRelationship(t *testing.T) { + steps := []testdb.Step{ + {Match: "SELECT DISTINCT", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}}, + {Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}}, + {Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}}, + } + 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} + if err := db.AdvanceUnbindings(); err == nil { + t.Fatal("failure ignored") + } + if s.Commits != 0 || s.Rollbacks != 1 { + t.Fatalf("commits=%d rollbacks=%d", s.Commits, s.Rollbacks) + } +} +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 { + t.Fatal(err) + } + if s.Commits != 1 { + t.Fatal("migration marker not committed") + } +} + +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 fmt.Errorf("lost ledger id/owner/balance: %v", a) + } + if key != "" && a[8] != key { + return fmt.Errorf("latest retry key changed") + } + if key == "" && (a[8] == "same-key" || len(a[8].(string)) > 64) { + return fmt.Errorf("key collision not resolved") + } + return nil + } + } + 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: "SUM(stock)", Columns: []string{"user_id", "stock"}, Rows: [][]driver.Value{{int64(1), int64(19)}}}, + {Match: "INSERT INTO smoking_wallets", Args: []any{"1", "19"}}, + {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'"}, + }) + if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil { + t.Fatal(err) + } + if s.Commits != 1 { + t.Fatal("migration was not atomic") + } +} + +func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) { + db, script := testdb.Open(t, []testdb.Step{ + {Match: "INSERT IGNORE 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'"}, + }) + if err := (&DB{DB: 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/schema.go b/past-go/internal/store/schema.go index 44f3547..c144642 100644 --- a/past-go/internal/store/schema.go +++ b/past-go/internal/store/schema.go @@ -140,6 +140,26 @@ func (db *DB) InitSchema() error { } } + 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 } diff --git a/past-go/internal/store/schema_sql.go b/past-go/internal/store/schema_sql.go index e8e096a..7d8382b 100644 --- a/past-go/internal/store/schema_sql.go +++ b/past-go/internal/store/schema_sql.go @@ -5,6 +5,12 @@ package store // 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, @@ -172,6 +178,37 @@ var createTables = []string{ 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, @@ -366,6 +403,10 @@ var createTables = []string{ // 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"}, diff --git a/past-go/internal/store/unbinding_cleanup.go b/past-go/internal/store/unbinding_cleanup.go new file mode 100644 index 0000000..5dd4375 --- /dev/null +++ b/past-go/internal/store/unbinding_cleanup.go @@ -0,0 +1,148 @@ +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_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 albums WHERE couple_id = ?", + "DELETE FROM diaries WHERE couple_id = ?", + "DELETE FROM timeline WHERE couple_id = ?", + "DELETE FROM songs WHERE couple_id = ?", + "DELETE FROM checkins WHERE couple_id = ?", + "DELETE FROM period_records WHERE couple_id = ?", + "DELETE FROM period_daily_records WHERE couple_id = ?", + "DELETE FROM period_sex_records WHERE couple_id = ?", + "DELETE FROM pet_dailies WHERE couple_id = ?", + "DELETE FROM pet_achievements WHERE couple_id = ?", + "DELETE FROM pet_items WHERE couple_id = ?", + "DELETE FROM pet_dynamics WHERE couple_id = ?", + "DELETE FROM pets WHERE couple_id = ?", + "DELETE FROM activity_log WHERE couple_id = ?", + "DELETE FROM notify_log 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 +} diff --git a/past-go/internal/store/unbinding_cleanup_test.go b/past-go/internal/store/unbinding_cleanup_test.go new file mode 100644 index 0000000..2bda726 --- /dev/null +++ b/past-go/internal/store/unbinding_cleanup_test.go @@ -0,0 +1,61 @@ +package store + +import ( + "database/sql/driver" + "os" + "past/internal/testdb" + "path/filepath" + "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) + } + } + 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 new file mode 100644 index 0000000..013a13d --- /dev/null +++ b/past-go/internal/testdb/script.go @@ -0,0 +1,144 @@ +// Package testdb supplies a deterministic database/sql driver for transaction failure tests. +package testdb + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "io" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" +) + +type Step struct { + Check func(string, []any) error + + Match string + Args []any + Columns []string + Rows [][]driver.Value + Err error + ID int64 +} +type Script struct { + Steps []Step + Index int + Commits, Rollbacks int + mu sync.Mutex +} + +var scripts sync.Map +var sequence atomic.Int64 + +func init() { sql.Register("past_test_script", scriptDriver{}) } +func Open(t *testing.T, steps []Step) (*sql.DB, *Script) { + t.Helper() + name := fmt.Sprint(sequence.Add(1)) + s := &Script{Steps: steps} + scripts.Store(name, s) + db, err := sql.Open("past_test_script", name) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { + db.Close() + scripts.Delete(name) + if s.Index != len(s.Steps) { + t.Errorf("executed %d of %d SQL steps", s.Index, len(s.Steps)) + } + }) + return db, s +} + +type scriptDriver struct{} + +func (scriptDriver) Open(name string) (driver.Conn, error) { + s, _ := scripts.Load(name) + return &conn{s.(*Script)}, nil +} + +type conn struct{ s *Script } + +func (c *conn) Prepare(string) (driver.Stmt, error) { + return nil, fmt.Errorf("unexpected prepared statement") +} +func (c *conn) Close() error { return nil } +func (c *conn) Begin() (driver.Tx, error) { return &tx{c.s}, nil } +func (c *conn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { return c.Begin() } +func (c *conn) step(query string, args []driver.NamedValue) (Step, error) { + c.s.mu.Lock() + defer c.s.mu.Unlock() + if c.s.Index >= len(c.s.Steps) { + return Step{}, fmt.Errorf("unexpected SQL %s", query) + } + s := c.s.Steps[c.s.Index] + c.s.Index++ + if !strings.Contains(query, s.Match) { + return s, fmt.Errorf("expected SQL containing %q got %s", s.Match, query) + } + if s.Args != nil { + values := make([]any, len(args)) + for i, a := range args { + values[i] = a.Value + } + if !reflect.DeepEqual(values, s.Args) { + return s, fmt.Errorf("SQL args %v wanted %v", values, s.Args) + } + } + if s.Check != nil { + values := make([]any, len(args)) + for i, a := range args { + values[i] = a.Value + } + if err := s.Check(query, values); err != nil { + return s, err + } + } + return s, s.Err +} +func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue) (driver.Rows, error) { + s, err := c.step(q, a) + if err != nil { + return nil, err + } + return &rows{columns: s.Columns, data: s.Rows}, nil +} +func (c *conn) ExecContext(_ context.Context, q string, a []driver.NamedValue) (driver.Result, error) { + s, err := c.step(q, a) + if err != nil { + return nil, err + } + return result{s.ID}, nil +} + +type tx struct{ s *Script } + +func (t *tx) Commit() error { t.s.Commits++; return nil } +func (t *tx) Rollback() error { t.s.Rollbacks++; return nil } + +type result struct{ id int64 } + +func (r result) LastInsertId() (int64, error) { return r.id, nil } +func (r result) RowsAffected() (int64, error) { return 1, nil } + +type rows struct { + columns []string + data [][]driver.Value + index int +} + +func (r *rows) Columns() []string { return r.columns } +func (r *rows) Close() error { return nil } +func (r *rows) Next(dest []driver.Value) error { + if r.index >= len(r.data) { + return io.EOF + } + copy(dest, r.data[r.index]) + r.index++ + return nil +} diff --git a/past-go/web/admin/src/pages/Unbind.tsx b/past-go/web/admin/src/pages/Unbind.tsx index ca64717..cf9763d 100644 --- a/past-go/web/admin/src/pages/Unbind.tsx +++ b/past-go/web/admin/src/pages/Unbind.tsx @@ -1,7 +1,6 @@ import { useState } from "react" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query" import { RefreshCw } from "lucide-react" -import { toast } from "sonner" import { api, fmtTime } from "@/lib/api" import { Avatar } from "@/components/ImagePreview" import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination" @@ -12,44 +11,31 @@ 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" -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" const STATUS: Record = { pending: { label: "⏳ 待回应", variant: "outline" }, - agreed: { label: "✅ 已同意(已解绑)", variant: "default" }, + cooling: { label: "冷静期", variant: "secondary" }, + completed: { label: "解绑完成", variant: "default" }, + agreed: { label: "历史已同意", variant: "outline" }, rejected: { label: "❌ 已拒绝", variant: "destructive" }, cancelled: { label: "↩️ 已撤销", variant: "secondary" }, expired: { label: "⏰ 已过期", variant: "secondary" }, } -type Action = { kind: "restore" | "delete"; coupleId: number; name: string; step: number } - export default function UnbindPage() { - const qc = useQueryClient() const [filter, setFilter] = useState("all") - const [action, setAction] = useState(null) const [page, setPage] = useState(1) const q = useQuery({ queryKey: ["admin_unbind_requests", filter, page], + refetchInterval: 30000, queryFn: () => api("adminUnbindRequests", { status: filter === "all" ? undefined : filter, page, page_size: PAGE_SIZE }, "GET"), }) const rows = q.data?.items || [] - async function run() { - if (!action) return - // 删除数据需要二次确认(与旧版一致) - if (action.kind === "delete" && action.step === 0) return setAction({ ...action, step: 1 }) - try { - const r = await api(action.kind === "restore" ? "admin_restore_couple" : "admin_delete_couple_data", { couple_id: action.coupleId }) - toast.success(r.msg || "操作成功 ✅") - qc.invalidateQueries({ queryKey: ["admin_unbind_requests"] }) - qc.invalidateQueries({ queryKey: ["admin_couples"] }) - } catch (e) { toast.error((e as Error).message) } finally { setAction(null) } - } - return (
解绑管理 +

对方同意后进入72小时冷静期,期间双方均可撤销;到期系统自动解绑并删除共同数据,无需管理员审批。

@@ -61,7 +47,7 @@ export default function UnbindPage() { {q.isPending ? : q.isError ? : !rows.length ? : (
- 情侣发起方状态发起时间回应时间操作 + 情侣发起方状态发起时间回应时间冷静期截止撤销时间 / 撤销方解绑完成时间数据状态 {rows.map((r, i) => { const name = `${r.user1_name || "?"} 💕 ${r.user2_name || "?"}` @@ -74,16 +60,10 @@ export default function UnbindPage() { {restored ? 🔄 已恢复关系 : {st?.label || r.status}} {fmtTime(r.created_at)} {fmtTime(r.responded_at)} - - {r.status === "agreed" && !restored ? ( -
- - -
- ) : restored ? ✅ 已恢复 - : r.status === "pending" ? ⏳ 等待双方同意 - : -} -
+ {fmtTime(r.cooling_until)} + {fmtTime(r.cancelled_at)}{r.cancelled_by_name &&
{r.cancelled_by_name}
}
+ {fmtTime(r.completed_at)} + {Number(r.data_deleted) === 1 ? "共同数据已删除" : r.status === "cooling" ? "到期自动删除" : "数据保留"} ) })} @@ -95,26 +75,7 @@ export default function UnbindPage() { - !o && setAction(null)}> - - - {action?.kind === "restore" ? "🔄 恢复情侣关系" : action?.step === 1 ? "⚠️ 再次确认" : "⚠️ 永久删除情侣数据"} - - {action?.kind === "restore" - ? `确定要恢复「${action.name}」的情侣关系吗?\n所有数据将恢复,双方用户可继续使用。` - : action?.step === 1 - ? "此操作将彻底删除所有情侣数据。确定继续吗?" - : `确定要永久删除「${action?.name}」的所有情侣数据吗?\n\n此操作将:\n1. 删除所有情侣内容(日记、记忆日、相册、照片、歌曲、打卡等)\n2. 保留双方用户账号,可重新邀请绑定\n3. 删除情侣空间文件夹\n\n此操作不可恢复!`} - - - - 取消 - { e.preventDefault(); run() }}> - {action?.kind === "restore" ? "确认恢复" : action?.step === 1 ? "确认删除" : "继续"} - - - - + ) } diff --git a/weapp/app.js b/weapp/app.js index e34e491..3a9d7fa 100644 --- a/weapp/app.js +++ b/weapp/app.js @@ -23,6 +23,29 @@ App({ }); }, + onRelationChanged: function() { + if (this._relationRedirect) return; + wx.removeStorageSync('petHasPet'); + this._relationRedirect = true; + setTimeout(() => { this._relationRedirect=false; wx.reLaunch({url:'/pages/index/index'}); }, 0); + }, + onShow: function() { + const sync = async () => { + if (this._relationSync || this.globalData.authState !== 'ready') return; + this._relationSync = true; + const before = String(wx.getStorageSync('coupleId') || ''); + try { + await api.checkAuth(); + if (before !== String(wx.getStorageSync('coupleId') || '')) { + this.onRelationChanged(); + } + } catch(e) {} finally { this._relationSync = false; } + }; + sync(); + if (!this._relationTimer) this._relationTimer = setInterval(sync, 30000); + }, + onHide: function() { if(this._relationTimer) clearInterval(this._relationTimer); this._relationTimer=null; }, + setAuthState: function(state, message) { this.globalData.authState = state; this.globalData.authMessage = message || ''; diff --git a/weapp/app.json b/weapp/app.json index afd5b34..cc4ef18 100644 --- a/weapp/app.json +++ b/weapp/app.json @@ -19,13 +19,16 @@ "pages/diary_detail/diary_detail", "pages/diary_edit/diary_edit", "pages/pet/pet", - "pages/pet/achievements" + "pages/pet/achievements", + "pages/smoking-detail/smoking-detail" ], "window": { "backgroundTextStyle": "light", "navigationStyle": "custom", "navigationBarTextStyle": "black", - "backgroundColor": "#FAF5EE" + "backgroundColor": "#FDE4EC", + "backgroundColorTop": "#FDE4EC", + "backgroundColorBottom": "#FFF7FA" }, "style": "v2", "sitemapLocation": "sitemap.json", @@ -43,5 +46,25 @@ ], "usingComponents": { "launch-transition": "/components/launch-transition/launch-transition" + }, + "tabBar": { + "custom": true, + "color": "#8B827B", + "selectedColor": "#B74768", + "backgroundColor": "#FFF0F5", + "list": [ + { + "pagePath": "pages/index/index", + "text": "我们" + }, + { + "pagePath": "pages/discover/discover", + "text": "记录" + }, + { + "pagePath": "pages/profile/profile", + "text": "我的" + } + ] } } diff --git a/weapp/app.wxss b/weapp/app.wxss index d269a14..ab289f6 100644 --- a/weapp/app.wxss +++ b/weapp/app.wxss @@ -20,7 +20,7 @@ page { --text-main: #333333; --text-sub: #888888; --text-light: #BBBBBB; - --bg: #FAF5EE; + --bg: #FFF7FA; --bg-soft: #F3EBE1; --white: #FFFFFF; @@ -295,3 +295,5 @@ scroll-view ::-webkit-scrollbar { display: none; } border: none; } .empty-btn::after { border: none; } + +@import './styles/typography.wxss'; diff --git a/weapp/components/app-nav/app-nav.js b/weapp/components/app-nav/app-nav.js index d614448..3cb2b9f 100644 --- a/weapp/components/app-nav/app-nav.js +++ b/weapp/components/app-nav/app-nav.js @@ -7,6 +7,6 @@ Component({ methods: { goBack: function() { this.triggerEvent('back'); if (getCurrentPages().length > 1) wx.navigateBack(); - else wx.reLaunch({ url: '/pages/index/index' }); + else wx.switchTab({ url: '/pages/index/index' }); } } }); diff --git a/weapp/components/app-nav/app-nav.wxss b/weapp/components/app-nav/app-nav.wxss index d47ed9e..fd191d4 100644 --- a/weapp/components/app-nav/app-nav.wxss +++ b/weapp/components/app-nav/app-nav.wxss @@ -1 +1,5 @@ -.nav{position:fixed;top:0;left:0;right:0;z-index:900;background:#FAF5EE;box-sizing:border-box}.bar{position:relative;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.title{font-size:32rpx;font-weight:600;color:#352E2A;white-space:nowrap}.back{position:absolute;left:20rpx;top:0;bottom:0;width:80rpx;display:flex;align-items:center;justify-content:center}.back image{width:36rpx;height:36rpx} \ No newline at end of file +.nav{position:fixed;top:0;left:0;right:0;z-index:900;background:#FDE4EC;box-sizing:border-box}.bar{position:relative;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.title{font-size:32rpx;font-weight:600;color:#352E2A;white-space:nowrap}.back{position:absolute;left:20rpx;top:0;bottom:0;width:80rpx;display:flex;align-items:center;justify-content:center}.back image{width:36rpx;height:36rpx} +/* 二级页面导航标题也统一,组件隔离下需在组件内部设置字体。 */ +.title { font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-weight:400; font-style:normal; letter-spacing:1rpx; } + +@import '../../styles/typography.wxss'; diff --git a/weapp/components/app-tabbar/app-tabbar.js b/weapp/components/app-tabbar/app-tabbar.js index f0f3257..46b50c8 100644 --- a/weapp/components/app-tabbar/app-tabbar.js +++ b/weapp/components/app-tabbar/app-tabbar.js @@ -15,6 +15,6 @@ Component({ var pages = getCurrentPages(); // 当前主页面重复点击不重新加载。 if (pages.length && pages[pages.length - 1].route === paths[tab]) return; - wx.reLaunch({ url: '/' + paths[tab] }); + wx.switchTab({ url: '/' + paths[tab] }); } } }); diff --git a/weapp/components/app-tabbar/app-tabbar.wxss b/weapp/components/app-tabbar/app-tabbar.wxss index 9775120..b3ea91e 100644 --- a/weapp/components/app-tabbar/app-tabbar.wxss +++ b/weapp/components/app-tabbar/app-tabbar.wxss @@ -6,8 +6,8 @@ z-index: 900; display: flex; align-items: center; - background: #FFFDF8; - border-top: 1rpx solid #E9DDD0; + background: #FFF0F5; + border-top: 1rpx solid #F3DCE5; padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom)); box-sizing: border-box; } @@ -36,6 +36,8 @@ .tab-icon image { width: 48rpx; height: 48rpx; } .tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; } .active { color: #B74768; } -.active .tab-icon { background: #F4E2E3; } +.active .tab-icon { background: #FADCE7; } .active .tab-label { font-weight: 600; } -.tab-pressed .tab-icon { background: #F3EBE1; } +.tab-pressed .tab-icon { background: #FCE5ED; } + +@import '../../styles/typography.wxss'; diff --git a/weapp/components/launch-transition/launch-transition.js b/weapp/components/launch-transition/launch-transition.js index 117a941..11081ee 100644 --- a/weapp/components/launch-transition/launch-transition.js +++ b/weapp/components/launch-transition/launch-transition.js @@ -1,17 +1,32 @@ Component({ - data: { visible: true, state: 'loading', message: '' }, + data: { visible: false, intro: false, state: 'loading', message: '' }, lifetimes: { attached: function() { this._startedAt = Date.now(); + try { this._showIntro = !wx.getStorageSync('launchIntroSeen'); } catch (e) { this._showIntro = false; } + if (this._showIntro) { + this.setData({ visible: true, intro: true }); + // 设备首次展示即记住,跳转页面或再次启动都不会重复展示。 + try { wx.setStorageSync('launchIntroSeen', true); } catch (e) {} + } this._unobserve = getApp().observeAuth((state, message) => { clearTimeout(this._exitTimer); - if (state === 'ready' && !this._waiting) { - this.setData({ visible: false }); - } else if (state === 'ready') { - this._exitTimer = setTimeout(() => { this.setData({ visible: false }); }, Math.max(0, 350 - (Date.now() - this._startedAt))); + if (state === 'ready') { + this._retrying = false; + if (this._showIntro) { + this._exitTimer = setTimeout(() => { + this._showIntro = false; + this.setData({ visible: false, intro: false }); + }, Math.max(0, 350 - (Date.now() - this._startedAt))); + } else { + this.setData({ visible: false, intro: false }); + } + } else if (state === 'error') { + this._showIntro = false; + this._retrying = true; + this.setData({ visible: true, intro: false, state: state, message: message }); } else { - this._waiting = true; - this.setData({ visible: true, state: state, message: message }); + this.setData({ visible: !!(this._showIntro || this._retrying), intro: !!this._showIntro, state: state, message: message }); } }); }, diff --git a/weapp/components/launch-transition/launch-transition.wxml b/weapp/components/launch-transition/launch-transition.wxml index 53614a8..0b76b93 100644 --- a/weapp/components/launch-transition/launch-transition.wxml +++ b/weapp/components/launch-transition/launch-transition.wxml @@ -1,13 +1,16 @@ - 💝 - 我们 - 把日常,写成我们的故事 + + + 我们past + 让所有的爱都有迹可循 + + 连接暂时没有成功 {{message}} - PAGES OF US + PAGES OF US diff --git a/weapp/components/launch-transition/launch-transition.wxss b/weapp/components/launch-transition/launch-transition.wxss index 7d723eb..f1d8db9 100644 --- a/weapp/components/launch-transition/launch-transition.wxss +++ b/weapp/components/launch-transition/launch-transition.wxss @@ -1,4 +1,4 @@ -.launch-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:99999;background:#FAF5EE;display:flex;align-items:center;justify-content:center;color:#352E2A;} +.launch-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:99999;background:linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%);display:flex;align-items:center;justify-content:center;color:#352E2A;} .launch-content{display:flex;align-items:center;flex-direction:column;width:100%;padding:0 60rpx;box-sizing:border-box;transform:translateY(-40rpx);} .launch-mark{display:block;width:152rpx;height:152rpx;line-height:152rpx;font-size:120rpx;text-align:center;} .launch-animated{animation:book-breathe 1.8s ease-in-out infinite;} @@ -12,3 +12,7 @@ .launch-footer{position:absolute;bottom:calc(56rpx + env(safe-area-inset-bottom));font-size:20rpx;letter-spacing:6rpx;color:#A39386;} @keyframes book-breathe{0%,100%{transform:translateY(0) rotate(-3deg);}50%{transform:translateY(-10rpx) rotate(1deg);}} @keyframes dot-breathe{0%,100%{opacity:.25;}50%{opacity:1;}} + +@import '../../styles/button-theme.wxss'; + +@import '../../styles/typography.wxss'; diff --git a/weapp/components/page-state/page-state.wxss b/weapp/components/page-state/page-state.wxss index 567607e..db3bba5 100644 --- a/weapp/components/page-state/page-state.wxss +++ b/weapp/components/page-state/page-state.wxss @@ -2,3 +2,7 @@ .state button{min-height:80rpx;line-height:1.4;display:flex;align-items:center;justify-content:center;padding:20rpx 40rpx;box-sizing:border-box;flex-shrink:0;min-width:220rpx} .state-empty { background:#FFFDF8; border:1rpx dashed #E9DDD0; border-radius:12rpx 50rpx 12rpx 50rpx; margin:20rpx 0; }.mark { background:transparent; border-radius:0; }.state button { border-radius:26rpx; }.state .desc { font-size:24rpx; line-height:1.8; } + +@import '../../styles/button-theme.wxss'; + +@import '../../styles/typography.wxss'; diff --git a/weapp/components/smoking-card/smoking-card.js b/weapp/components/smoking-card/smoking-card.js new file mode 100644 index 0000000..53dfc44 --- /dev/null +++ b/weapp/components/smoking-card/smoking-card.js @@ -0,0 +1,76 @@ +const { api, getImageUrl } = require('../../utils/request'); +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:[], ownerName:'', ownerAvatar:'', ownerInitial:'我' }, + lifetimes: { attached(){this.load();} }, + pageLifetimes: { show(){this.load();} }, + methods: { + async load(force) { + if(this._loading) return this._loading; + if(force)cache.invalidate(['smokingInfo']); + this.setData({error:false}); + const target = this.data.viewUserId; + this._loading=Promise.all([api.smokingInfo(target), api.coupleInfo().catch(()=>null)]).then(([info,profile])=>{if(target===this.data.viewUserId){this._profile=profile;this.applyInfo(info);}}).catch(()=>this.setData({error:true})).finally(()=>{this._loading=null;if(target===this.data.viewUserId)this.setData({loading:false});}); + return this._loading; + }, + applyInfo(info) { + const logs=(info.logs||[]).map(log=>({...log,undone:log.undone === true || Number(log.undone) === 1,title:titles[log.kind]||log.kind,change:Number(log.delta)>0?'+'+log.delta:String(log.delta),time:String(log.created_at||'').slice(5,16),money:Number(log.amount_cents)>0?(Number(log.amount_cents)/100).toFixed(2):''})); + let lastLabel='还没有记录'; + if(info.last_time){const minutes=Math.max(0,Math.floor((Date.now()-new Date(String(info.last_time).replace(/-/g,'/')).getTime())/60000));lastLabel=minutes<60?'距上次 '+minutes+' 分钟':minutes<1440?'距上次 '+Math.floor(minutes/60)+' 小时':'距上次 '+Math.floor(minutes/1440)+' 天';} + const map={};(info.daily||[]).forEach(row=>{map[row.day]=Number(row.count);});const daily=[]; + for(let ago=6;ago>=0;ago--){const day=new Date();day.setDate(day.getDate()-ago);const key=day.getFullYear()+'-'+String(day.getMonth()+1).padStart(2,'0')+'-'+String(day.getDate()).padStart(2,'0');daily.push({day:key,label:ago===0?'今天':(day.getMonth()+1)+'/'+day.getDate(),count:map[key]||0});} + const max=Math.max(1,...daily.map(day=>day.count));daily.forEach(day=>day.height=Math.max(4,day.count/max*100)); + 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 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,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false}); + }, + 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.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,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});}, + close(){if(!this.data.saving)this.setData({sheet:false});}, + preventMove(){}, + smoke(){return this.act({kind:'consume',quantity:1});}, + receiveSmoke(){return this.act({kind:'receive_smoke',quantity:1});}, + giveSmoke(){ + if(this.data.saving || !this.data.info || this.data.info.read_only || !this.data.info.available)return; + if(Number(this.data.info.stock)<1)return wx.showToast({title:'库存不足,请先补充库存',icon:'none'}); + return this.act({kind:'give',quantity:1}); + }, + submit() { + const mode=this.data.mode; + const quantityText=this.data.quantity.trim(),packText=this.data.packSize.trim(); + if(!/^\d+$/.test(quantityText)||(mode==='purchase'&&!/^\d+$/.test(packText)))return wx.showToast({title:'请填写整数数量',icon:'none'}); + let quantity=Number(quantityText);if(mode==='purchase'){if(Number(packText)<1||Number(packText)>100)return wx.showToast({title:'每包数量应为1到100支',icon:'none'});quantity*=Number(packText);} + if(quantity>(mode==='adjust'?1000000:10000)||(mode!=='adjust'&&quantity<1))return wx.showToast({title:'数量超出范围',icon:'none'}); + const money=this.data.amount.trim();if(mode==='purchase'&&money&&!/^\d+(\.\d{1,2})?$/.test(money))return wx.showToast({title:'金额最多两位小数',icon:'none'}); + const amount=mode==='purchase'?Math.round(Number(money||0)*100):0; + 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});}});}, + 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); + if(!this._pendingAction||this._pendingAction.signature!==signature)this._pendingAction={signature,key:'smoke-'+Date.now()+'-'+Math.random().toString(36).slice(2)}; + this.setData({saving:true}); + try { + const result=await api.smokingAction({...payload,request_key:this._pendingAction.key}); + if(!result.success)throw result; + this._pendingAction=null;this.applyInfo(result.info);this.setData({sheet:false});this.triggerEvent('updated');wx.showToast({title:'已记录',icon:'none'}); + } catch(error) { + // 明确的业务拒绝不会提交;网络不确定时保留请求标识,重试不会重复扣减。 + if(error&&error._statusCode&&error._statusCode!==500)this._pendingAction=null; + wx.showToast({title:error&&error.error||'请求失败,请重试',icon:'none'}); + } finally {this.setData({saving:false});} + } + } +}); diff --git a/weapp/components/smoking-card/smoking-card.json b/weapp/components/smoking-card/smoking-card.json new file mode 100644 index 0000000..7e209a4 --- /dev/null +++ b/weapp/components/smoking-card/smoking-card.json @@ -0,0 +1 @@ +{"component":true,"usingComponents":{}} diff --git a/weapp/components/smoking-card/smoking-card.wxml b/weapp/components/smoking-card/smoking-card.wxml new file mode 100644 index 0000000..f28110e --- /dev/null +++ b/weapp/components/smoking-card/smoking-card.wxml @@ -0,0 +1,26 @@ + + 默认事件 · 按支记录抽烟记录明细 › + 我的记录TA的记录 + 正在加载记录… + 记录加载失败,点击重试 + + {{info.today_count}}支今日已抽{{info.stock}}支剩余库存 + {{info.read_only ? info.owner_name + ' · 仅查看' : lastLabel}} + 绑定伴侣后可记录个人库存和打卡 + + + {{saving ? '正在保存…' : '别人递来一支,当场抽掉 →'}}递给别人一支 → + 还没有库存,先录入已有的烟或补充库存 + + {{logs[0].title}} · {{logs[0].time}}撤销 + + 最近七天累计 {{info.total_count}} 支 + {{item.count}}{{item.label}} + 库存与打卡流水最近 50 条 + 从补充库存开始,记录每一次消耗 + {{ownerInitial}}{{ownerName}}{{item.title}}{{item.undone ? ' · 已撤销' : ''}}{{item.time}}{{item.money ? ' · ¥' + item.money : ''}}{{item.note}}{{item.change}} 支 · 余 {{item.balance}}撤销 + + + +补充与调整库存关闭买烟别人递烟调整库存 +{{mode === 'purchase' ? '购买包数' : (mode === 'adjust' ? '盘点后实际剩余(支)' : '收到数量(支)')}}每包支数购买金额(选填,元)备注(选填){{mode === 'adjust' ? '库存将调整为填写的数量,不计入抽烟打卡。' : '只增加库存,不计入抽烟打卡。'}} diff --git a/weapp/components/smoking-card/smoking-card.wxss b/weapp/components/smoking-card/smoking-card.wxss new file mode 100644 index 0000000..a2bf6c5 --- /dev/null +++ b/weapp/components/smoking-card/smoking-card.wxss @@ -0,0 +1,14 @@ +.smoke-card{padding:32rpx;border:1rpx solid #F1DCE4;border-radius:28rpx;background:#FFFCFD;margin-bottom:28rpx;color:#352E2A}.smoke-head{display:flex;justify-content:space-between;align-items:center}.smoke-eyebrow{display:block;font-size:21rpx;color:#A39386;margin-bottom:10rpx}.smoke-title{font-size:33rpx;font-weight:600}.smoke-link{font-size:24rpx;color:#B74768}.smoke-stats{display:flex;margin:30rpx 0 20rpx}.smoke-stats>view{flex:1}.smoke-stats>view+view{padding-left:32rpx;border-left:1rpx solid #F1DCE4}.smoke-number{display:block;font-size:54rpx;color:#B74768;line-height:1.2}.smoke-number text{font-size:23rpx;margin-left:10rpx;color:#A39386}.smoke-label{display:block;font-size:23rpx;color:#85776D;margin-top:10rpx}.smoke-hint{display:block;font-size:22rpx;color:#A39386;line-height:1.7;margin:12rpx 0}.smoke-actions{display:flex;gap:16rpx;margin-top:24rpx}.smoke-primary,.smoke-secondary{flex:1;margin:0;border-radius:20rpx;font-size:25rpx;height:88rpx;min-height:88rpx;padding:0;display:flex;align-items:center;justify-content:center;line-height:normal;box-sizing:border-box}.smoke-primary{background:#B74768;color:#FFF}.smoke-secondary{background:#FFF0F5;color:#B74768;border:1rpx solid #F1DCE4}button::after{border:0}.smoke-primary[disabled]{background:#F0E2E8;color:#A39386}.smoke-inline{font-size:22rpx;color:#B74768;padding:24rpx 0 10rpx}.smoke-recent{display:flex;justify-content:space-between;gap:16rpx;border-top:1rpx solid #F1DCE4;margin-top:20rpx;padding-top:20rpx;font-size:22rpx;color:#A39386}.smoke-recent text+text{color:#B74768}.owners,.sheet-tabs{display:flex;gap:10rpx;margin:24rpx 0}.owner,.sheet-tabs>view{flex:1;text-align:center;background:#FFF5F8;color:#85776D;padding:18rpx 8rpx;border-radius:16rpx;font-size:24rpx}.owners .selected,.sheet-tabs .selected{background:#F4E2E3;color:#B74768;font-weight:600}.section-title{font-size:27rpx;font-weight:600;display:flex;justify-content:space-between;margin:40rpx 0 22rpx}.section-title text{font-size:20rpx;color:#A39386;font-weight:400}.week-chart{display:flex;gap:10rpx}.chart-day{flex:1;text-align:center;font-size:20rpx;color:#A39386}.chart-track{height:110rpx;display:flex;align-items:flex-end;justify-content:center;margin:12rpx 0}.chart-bar{width:24rpx;border-radius:8rpx;background:#D5A6AE}.ledger{display:flex;justify-content:space-between;gap:20rpx;padding:24rpx 0;border-top:1rpx solid #F1DCE4;font-size:24rpx}.ledger-main{flex:1;min-width:0}.ledger-main text{display:block;overflow-wrap:break-word}.ledger-main text+text{font-size:21rpx;color:#A39386;margin-top:8rpx}.ledger-right{text-align:right;font-size:22rpx}.ledger-right text{display:block}.ledger-right text+text{color:#B74768;padding-top:14rpx}.undone{opacity:.55}.smoke-mask{position:fixed;inset:0;z-index:1300;display:flex;align-items:flex-end}.smoke-backdrop{position:absolute;inset:0;background:rgba(53,46,42,.35)}.smoke-sheet{position:relative;width:100%;max-height:90vh;background:#FFFCFD;border-radius:40rpx 40rpx 0 0;padding:16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));box-sizing:border-box}.sheet-grip{width:64rpx;height:7rpx;background:#EBD5DE;border-radius:8rpx;margin:0 auto 26rpx}.sheet-head{display:flex;justify-content:space-between;font-size:30rpx;font-weight:600}.sheet-head text+text{font-size:24rpx;color:#B74768;font-weight:400}.sheet-body{height:600rpx;max-height:52vh}.field-label{display:block;font-size:24rpx;color:#85776D;margin:20rpx 0 12rpx}.smoke-input{height:84rpx;width:100%;background:#FFF5F8;border:1rpx solid #F1DCE4;border-radius:16rpx;padding:0 24rpx;box-sizing:border-box;font-size:27rpx;color:#352E2A}.sheet-save{width:100%;margin-top:24rpx} + +.ledger-avatar{width:54rpx;height:54rpx;flex-shrink:0;border-radius:50%;overflow:hidden;background:#FADCE7;color:#B74768;display:flex;align-items:center;justify-content:center;font-size:24rpx} +.ledger-avatar image{width:100%;height:100%} +.ledger-owner{font-size:24rpx;color:#64424D;margin-bottom:10rpx;overflow-wrap:break-word} + +@import '../../styles/button-theme.wxss'; + +.smoke-inline-actions{display:flex;align-items:center;gap:20rpx} +.smoke-inline-actions .smoke-inline:first-child{flex:1;min-width:0} +.smoke-give{flex-shrink:0;text-align:right} +.smoke-give-disabled{color:#A39386;opacity:.65} + +@import '../../styles/typography.wxss'; diff --git a/weapp/custom-tab-bar/index.js b/weapp/custom-tab-bar/index.js new file mode 100644 index 0000000..06df7f8 --- /dev/null +++ b/weapp/custom-tab-bar/index.js @@ -0,0 +1,23 @@ +const { tabForPage, paths } = require('../utils/navigation'); +Component({ + data: { active: '', hidden: false }, + lifetimes: { attached: function() { this.syncActive(); } }, + pageLifetimes: { show: function() { this.syncActive(); } }, + methods: { + setHidden: function(hidden) { this.setData({ hidden: !!hidden }); }, + syncActive: function() { + var pages = getCurrentPages(); + var current = pages[pages.length - 1]; + this.setData({ active: tabForPage(current && current.route), hidden: !!(current && current.data && (current.data.publishOpen || current.data.showEditProfileModal || current.data.showEditPartnerModal)) }); + }, + navigate: function(e) { + var tab = e.currentTarget.dataset.tab; + if (!paths[tab]) return; + var pages = getCurrentPages(); + // 当前主页面重复点击不重新加载。 + if (pages.length && pages[pages.length - 1].route === paths[tab]) return; + if (this._switching) return; + this._switching = true; + wx.switchTab({ url: '/' + paths[tab], complete: () => { this._switching = false; } }); + } +} }); diff --git a/weapp/custom-tab-bar/index.json b/weapp/custom-tab-bar/index.json new file mode 100644 index 0000000..a8456c1 --- /dev/null +++ b/weapp/custom-tab-bar/index.json @@ -0,0 +1 @@ +{"component":true} \ No newline at end of file diff --git a/weapp/custom-tab-bar/index.wxml b/weapp/custom-tab-bar/index.wxml new file mode 100644 index 0000000..b69c853 --- /dev/null +++ b/weapp/custom-tab-bar/index.wxml @@ -0,0 +1,11 @@ + + + 我们 + + + 记录 + + + 我的 + + diff --git a/weapp/custom-tab-bar/index.wxss b/weapp/custom-tab-bar/index.wxss new file mode 100644 index 0000000..78105cd --- /dev/null +++ b/weapp/custom-tab-bar/index.wxss @@ -0,0 +1,43 @@ +.tabs { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 900; + display: flex; + align-items: center; + background: #FFF0F5; + border-top: 1rpx solid #F3DCE5; + padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} +.tab { + flex: 1; + min-width: 0; + min-height: 92rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4rpx; + padding: 4rpx 0; + color: #8B827B; +} +.tab-icon { + display: flex; + align-items: center; + justify-content: center; + width: 88rpx; + height: 54rpx; + border-radius: 32rpx; + background: transparent; + +} +.tab-icon image { width: 48rpx; height: 48rpx; } +.tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; } +.active { color: #B74768; } +.active .tab-icon { background: #FADCE7; } +.active .tab-label { font-weight: 600; } +.tab-pressed .tab-icon { background: #FCE5ED; } + +@import '../styles/typography.wxss'; diff --git a/weapp/images/back-white.svg b/weapp/images/back-white.svg new file mode 100644 index 0000000..b335c68 --- /dev/null +++ b/weapp/images/back-white.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/home-cover-heart.svg b/weapp/images/home-cover-heart.svg new file mode 100644 index 0000000..8daee2f --- /dev/null +++ b/weapp/images/home-cover-heart.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-album.svg b/weapp/images/stat-album.svg new file mode 100644 index 0000000..5f1f6a2 --- /dev/null +++ b/weapp/images/stat-album.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-book.svg b/weapp/images/stat-book.svg new file mode 100644 index 0000000..0e2805d --- /dev/null +++ b/weapp/images/stat-book.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-check.svg b/weapp/images/stat-check.svg new file mode 100644 index 0000000..67449f4 --- /dev/null +++ b/weapp/images/stat-check.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-j-flower.svg b/weapp/images/stat-j-flower.svg new file mode 100644 index 0000000..f343063 --- /dev/null +++ b/weapp/images/stat-j-flower.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-music.svg b/weapp/images/stat-music.svg new file mode 100644 index 0000000..e3b8725 --- /dev/null +++ b/weapp/images/stat-music.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-pet.svg b/weapp/images/stat-pet.svg new file mode 100644 index 0000000..058e15c --- /dev/null +++ b/weapp/images/stat-pet.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-settings.svg b/weapp/images/stat-settings.svg new file mode 100644 index 0000000..b33afcc --- /dev/null +++ b/weapp/images/stat-settings.svg @@ -0,0 +1 @@ + diff --git a/weapp/images/stat-timeline.svg b/weapp/images/stat-timeline.svg new file mode 100644 index 0000000..a01bd8a --- /dev/null +++ b/weapp/images/stat-timeline.svg @@ -0,0 +1 @@ + diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 9ea00ac..3a3cb98 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -1,11 +1,16 @@ +const { currentUserLocation } = require('../../utils/user-location'); +const { photoForm, metadataRows } = require('../../utils/photo-metadata'); +const { withDataCache } = require('../../utils/page-cache'); const { getLayout } = require('../../utils/layout'); // pages/album/album.js - 日记式分组相册 const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { requestSubscribeAfterAction } = require('../../utils/subscribe'); -Page({ +Page(withDataCache({ onResize: function() { this.setData(getLayout()); }, data: { loading: true, loadError: false, batches: [], + showPhotoEditor: false, editPhotos: [], editPhotoIdx: 0, editBatchId: '', + editForm: { taken_at: '', location: '', activity: '' }, savingPhoto: false, currentAddress: '', albumId: null, showUploadModal: false, uploadFilePaths: [], @@ -27,6 +32,67 @@ Page({ onShow: function() { this._visible = true; this.loadData(); }, onHide: function() { this._visible = false; }, + openPhotoEditor: function(e) { + const batch = this.data.batches[Number(e.currentTarget.dataset.batch)]; + if (!batch || !batch.photos.length) return; + this.setData({ showPhotoEditor: true, editBatchId: batch.batch_id, editPhotos: batch.photos, editPhotoIdx: 0, editForm: photoForm(batch.photos[0]) }); + this._loadCurrentAddress(); + }, + _loadCurrentAddress: function() { + return currentUserLocation().then(location => { this.setData({ currentAddress: location.location }); return location; }); + }, + onEditPhotoInput: function(e) { + const field = e.currentTarget.dataset.field; + if (['taken_at', 'location', 'activity'].indexOf(field) < 0 || this.data.savingPhoto) return; + this.setData({ editForm: { ...this.data.editForm, [field]: e.detail.value } }); + }, + clearPhotoDate: function() { + if (!this.data.savingPhoto) this.setData({ editForm: { ...this.data.editForm, taken_at: '' } }); + }, + _confirmPhotoDiscard: function(next) { + if (this.data.savingPhoto) return; + const original = photoForm(this.data.editPhotos[this.data.editPhotoIdx] || {}); + if (JSON.stringify(original) === JSON.stringify(this.data.editForm)) { next(); return; } + wx.showModal({ title: '放弃本次修改?', content: '当前照片的信息还没有保存。', confirmText: '放弃修改', success: res => { if (res.confirm && !this.data.savingPhoto) next(); } }); + }, + selectEditPhoto: function(e) { + const index = Number(e.currentTarget.dataset.index); + if (index === this.data.editPhotoIdx || !this.data.editPhotos[index]) return; + this._confirmPhotoDiscard(() => this.setData({ editPhotoIdx: index, editForm: photoForm(this.data.editPhotos[index]) })); + }, + closePhotoEditor: function() { + this._confirmPhotoDiscard(() => this.setData({ showPhotoEditor: false })); + }, + toggleMetadata: function(e) { + const index = Number(e.currentTarget.dataset.batch); + if (!this.data.batches[index]) return; + this.setData({ ['batches[' + index + '].metadataExpanded']: !this.data.batches[index].metadataExpanded }); + }, + preventEditorMove: function() {}, + savePhotoInfo: async function() { + if (this.data.savingPhoto) return; + const photo = this.data.editPhotos[this.data.editPhotoIdx]; + if (!photo || !photo.id) return; + const form = { taken_at: this.data.editForm.taken_at, location: this.data.editForm.location.trim(), activity: this.data.editForm.activity.trim() }; + this.setData({ savingPhoto: true }); + try { + if (!form.location) Object.assign(form, await currentUserLocation()); + const result = await api.photoUpdate({ id: photo.id, lat: 0, lng: 0, ...form }); + if (result && result.success && result.location !== undefined) form.location = result.location; + if (!result || !result.success) throw result || { error: '保存失败' }; + // 就地更新卡片和预览,不重载列表,不改变滚动位置。 + const batches = this.data.batches.map(batch => { + const photos = batch.photos.map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item); + return { ...batch, photos, metadata: metadataRows(photos) }; + }); + const previewImages = this.data.previewImages.map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item); + this.setData({ batches, previewImages, showPhotoEditor: false }); + wx.showToast({ title: '照片信息已保存', icon: 'success' }); + } catch (error) { + wx.showToast({ title: (error && (error.error || error.message)) || '保存失败,请重试', icon: 'none' }); + } finally { this.setData({ savingPhoto: false }); } + }, + // 左滑返回相册列表 touchStartX: 0, touchStartY: 0, onTouchStart: function(e) { @@ -66,7 +132,7 @@ Page({ loadData: async function() { if (this._pageLoading) return; this._pageLoading = true; - this.setData({loading:true, loadError:false}); + this.setData({loading: !this._hasCachedData, loadError:false}); try { var self = this; var albumId = this.data.albumId; @@ -78,8 +144,8 @@ Page({ } } if (albumId) { - // 使用 noCache 禁用缓存,确保获取最新数据 - var photos = await api.photos(albumId, null, 'GET', true); + // 共享内存缓存;写操作成功后自动失效。 + var photos = await api.photos(albumId); // 根据 batch_id 分组 var batchMap = {}; var myUid = wx.getStorageSync('userId'); @@ -115,6 +181,7 @@ Page({ // 按时间排序(最新的在最前) var batches = Object.values(batchMap); batches.sort(function(a, b) { return b.created_at > a.created_at ? 1 : -1; }); + batches.forEach(function(batch) { batch.metadata = metadataRows(batch.photos); }); this.setData({ batches: batches }); } else { this.setData({batches:[]}); } } catch (e) { @@ -142,6 +209,7 @@ Page({ wx.showToast({ title: '请选择照片', icon: 'none' }); return; } + self._loadCurrentAddress(); var formList = paths.map(function() { return { taken_at: '', location: '', activity: '' }; }); self.setData({ showUploadModal: true, @@ -152,6 +220,8 @@ Page({ }); }, fail: function(err) { + // 微信选图器关闭时也会触发 fail,主动取消无需提示错误。 + if (/cancel/i.test(String(err && err.errMsg || ''))) return; console.error('Choose image failed:', err); wx.showToast({ title: '选择照片失败', icon: 'none' }); } @@ -263,17 +333,7 @@ Page({ // 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示) var autoLoc = null; - var needLoc = formList.some(function(x) { return !(x.location && x.location.trim()); }); - if (needLoc) { - try { - autoLoc = await new Promise(function(resolve) { - wx.getFuzzyLocation({ - success: function(res) { resolve({ lat: res.latitude, lng: res.longitude }); }, - fail: function() { resolve(null); } - }); - }); - } catch (e) { autoLoc = null; } - } + if (formList.some(function(x) { return !String(x.location || '').trim(); })) autoLoc = await currentUserLocation(); for (var i = 0; i < paths.length; i++) { wx.showLoading({ title: '上传中 ' + (i + 1) + '/' + paths.length, mask: true }); @@ -293,7 +353,7 @@ Page({ album_id: String(albumId), path: uploadedPath, taken_at: f.taken_at || '', - location: f.location || '', + location: String(f.location || '').trim() || (autoLoc ? autoLoc.location : ''), activity: f.activity || '', batch_id: batchId, // 未手动填写位置时,自动定位并记录经纬度(发布后自动显示地址) @@ -426,11 +486,7 @@ Page({ if (!batch || !batch.photos || !batch.photos[imgIdx]) return; var allImages = []; batch.photos.forEach(function(p) { - // 经纬度为0(定位失败)时不显示位置信息 - var hasCoord = p.lat && p.lng && parseFloat(p.lat) !== 0 && parseFloat(p.lng) !== 0; - var loc = p.location; - if (!hasCoord) loc = ''; - allImages.push({ url: p.url, taken_at: p.taken_at, location: loc, activity: p.activity }); + allImages.push({ id: p.id, url: p.url, taken_at: p.taken_at, location: p.location || '', activity: p.activity }); }); this.setData({ showPreview: true, previewImages: allImages, previewIndex: imgIdx }); }, @@ -464,6 +520,6 @@ Page({ }, goToAlbums: function() { wx.navigateBack(); }, - goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, - goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } -}); + goHome: function() { wx.switchTab({ url: '/pages/index/index' }); }, + goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); } +}, {"loadData": ["photos", "albumList"]})); diff --git a/weapp/pages/album/album.wxml b/weapp/pages/album/album.wxml index a24e46a..67501e0 100644 --- a/weapp/pages/album/album.wxml +++ b/weapp/pages/album/album.wxml @@ -18,13 +18,38 @@ {{item.creator_name || '情侣'}} {{item.photos.length}}张 - {{item.created_time}} + ••• + 上传于 {{item.created_time}} + + + 补充拍摄时间、地点和故事 @@ -117,11 +142,11 @@ - + - + @@ -137,4 +162,23 @@ + + + + + + 给照片补一段故事{{editPhotos.length > 1 ? '选择照片,分别记录它的时刻' : '留住这一天,也留住当时的心情'}}× + + + 第 {{index + 1}} 张 + + 编辑这张照片的信息 + 拍摄时间清除{{editForm.taken_at || '选择拍摄日期'}}⌄ + 地点 · 留空使用当前地址 + 在做什么 - + + - - TA的资料 - - {{editPartnerNameInitial || '?'}} + + 编辑TA的资料 + + {{editPartnerNameInitial || '?'}}{{partnerAvatarUploading ? '正在上传头像…' : '点击更换头像'}} TA的ID: {{partnerDisplayId}} - {{editPartnerNickname || '未设置'}} - {{editPartnerGenderText || '-'}} - {{editPartnerBirthday || '-'}} - {{tempLoveDate || '未设置'}} - {{tempLoveStory || '未设置'}} - + + {{editPartnerGenderText || '请选择'}} + {{editPartnerBirthday || '请选择日期'}}自动计算:{{editPartnerAge}}岁 + {{tempLoveDate || '设置你们在一起的日期'}} +