diff --git a/past-go/cmd/server/main.go b/past-go/cmd/server/main.go index 2ac98c7..579736f 100644 --- a/past-go/cmd/server/main.go +++ b/past-go/cmd/server/main.go @@ -169,6 +169,7 @@ func serve(cfgPath string, stop <-chan struct{}) { } router := httpapi.NewRouter(app, web.AdminFS()) + go uploader.RunFileCleanupWorker() handler.Register(router, uploader) zap.S().Infow("已注册 action", "count", len(router.Actions())) diff --git a/past-go/config.dev.example.yaml b/past-go/config.dev.example.yaml index 3a64055..02f2837 100644 --- a/past-go/config.dev.example.yaml +++ b/past-go/config.dev.example.yaml @@ -13,7 +13,7 @@ db: wechat: appid: wxb463820bf36dd5d6 secret: "" # 在本地 config.dev.yaml 中填写 AppSecret - notify_template_id: "" # 待填写新 AppID 下的订阅消息模板 ID + notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" # 操作与打卡通知共用模板(15198) tencent_map_key: "" max_upload_mb: 50 # 定时补发通知间隔(分钟),0 关闭 diff --git a/past-go/config.example.yaml b/past-go/config.example.yaml index ac4f8f3..f97634d 100644 --- a/past-go/config.example.yaml +++ b/past-go/config.example.yaml @@ -12,7 +12,7 @@ db: wechat: appid: your_appid secret: your_secret - notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8 + notify_template_id: "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" # 操作与打卡通知共用模板(15198) tencent_map_key: "" max_upload_mb: 50 # 定时补发通知间隔(分钟),0 关闭 diff --git a/past-go/internal/config/config.go b/past-go/internal/config/config.go index 9b96843..5ba9305 100644 --- a/past-go/internal/config/config.go +++ b/past-go/internal/config/config.go @@ -112,7 +112,7 @@ func Load(path string) (*Config, error) { c.DB.SSLMode = "disable" c.MaxUploadMB = 50 c.NotifyIntervalMin = 5 - c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8" + c.Wechat.NotifyTemplateID = "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" c.PHPCompat = "8.0" b, err := os.ReadFile(path) diff --git a/past-go/internal/config/config_test.go b/past-go/internal/config/config_test.go index 39be6b6..80ecc25 100644 --- a/past-go/internal/config/config_test.go +++ b/past-go/internal/config/config_test.go @@ -35,8 +35,8 @@ func TestDevExampleConfig(t *testing.T) { if c.Listen != ":8080" || !c.Debug || c.NotifyIntervalMin != 0 || c.DB.Name != "past-go" || c.Minio.Bucket != "past-go" { t.Fatal("dev service configuration is incomplete") } - if c.Wechat.NotifyTemplateID != "" { - t.Fatal("dev configuration must not reuse old app payment or subscription credentials") + if c.Wechat.NotifyTemplateID != "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" { + t.Fatal("dev configuration must use the current app subscription template") } } diff --git a/past-go/internal/handler/account.go b/past-go/internal/handler/account.go new file mode 100644 index 0000000..d6778c5 --- /dev/null +++ b/past-go/internal/handler/account.go @@ -0,0 +1,58 @@ +package handler + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "past/internal/httpapi" + "past/internal/store" + "time" +) + +func deletionKey(c *httpapi.Ctx, token string) string { + h := sha256.Sum256([]byte(token)) + return c.App.Wechat.KeyPrefix + "account-delete:" + c.UID() + ":" + hex.EncodeToString(h[:]) +} +func accountDeletionPreview(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + s, err := c.DB.AccountDeletionSummary(c.UID()) + if errors.Is(err, store.ErrAccountBound) { + return c.Err(409, err.Error()) + } + if err != nil { + return err + } + token := httpapi.NewToken() + ctx, cancel := context.WithTimeout(c.R.Context(), 3*time.Second) + defer cancel() + if err := c.App.Wechat.Redis.Set(ctx, deletionKey(c, token), s.Revision, 5*time.Minute).Err(); err != nil { + return err + } + return c.OK(M{"summary": s, "confirmation_token": token, "expires_in": 300}) +} +func accountDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + if c.In("confirmed") != true || c.InStr("confirmation_text") != "注销并删除全部数据" || c.InStr("confirmation_token") == "" { + return c.Err(400, "请完成二次确认") + } + ctx, cancel := context.WithTimeout(c.R.Context(), 5*time.Second) + defer cancel() + revision, err := c.App.Wechat.Redis.Get(ctx, deletionKey(c, c.InStr("confirmation_token"))).Result() + if err != nil || revision == "" { + return c.Err(409, "确认已过期,请重新查看注销数据统计") + } + err = c.DB.DeleteAccount(c.UID(), revision) + if errors.Is(err, store.ErrAccountBound) || errors.Is(err, store.ErrAccountChanged) { + return c.Err(409, err.Error()) + } + if err != nil { + return err + } + c.App.Wechat.Redis.Del(ctx, deletionKey(c, c.InStr("confirmation_token"))) + return c.OK(M{"success": true, "message": "账号及内容已删除,独占的存储文件正在清理"}) +} diff --git a/past-go/internal/handler/announcement.go b/past-go/internal/handler/announcement.go new file mode 100644 index 0000000..360c2e4 --- /dev/null +++ b/past-go/internal/handler/announcement.go @@ -0,0 +1,94 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "past/internal/httpapi" + "strings" + "unicode/utf8" +) + +const ( + announcementOnce = "login_once" + announcementAlways = "always" + announcementOff = "off" +) + +type announcementConfig struct { + Mode string `json:"mode"` + Enabled bool `json:"enabled"` + Title string `json:"title"` + Content string `json:"content"` +} + +func readAnnouncement(c *httpapi.Ctx) (announcementConfig, error) { + cfg := announcementConfig{Title: "公告"} + value, _ := settingValue(c, "home_announcement") + if value != "" { + if err := json.Unmarshal([]byte(value), &cfg); err != nil { + return cfg, err + } + } + // Existing enabled-only settings become once per login, without losing their text. + if cfg.Mode == "" { + if cfg.Enabled { + cfg.Mode = announcementOnce + } else { + cfg.Mode = announcementOff + } + } + cfg.Enabled = cfg.Mode != announcementOff + return cfg, nil +} +func announcement(c *httpapi.Ctx) error { + cfg, err := readAnnouncement(c) + if err != nil { + return err + } + cfg.Enabled = cfg.Mode != announcementOff && strings.TrimSpace(cfg.Content) != "" + if !cfg.Enabled { + cfg.Content = "" + } + b, _ := json.Marshal(cfg) + sum := sha256.Sum256(b) + return c.OK(M{"mode": cfg.Mode, "enabled": cfg.Enabled, "title": cfg.Title, "content": cfg.Content, "revision": hex.EncodeToString(sum[:])}) +} +func adminAnnouncement(c *httpapi.Ctx) error { + if c.Method == "GET" { + cfg, err := readAnnouncement(c) + if err != nil { + return err + } + return c.OK(cfg) + } + if err := c.RequirePOST(); err != nil { + return err + } + mode := c.InStr("mode") + if !c.InHas("mode") { + enabled, ok := c.In("enabled").(bool) + if !ok { + return c.Err(400, "请选择通知方式") + } + mode = announcementOff + if enabled { + mode = announcementOnce + } + } + if mode != announcementOnce && mode != announcementAlways && mode != announcementOff { + return c.Err(400, "通知方式无效") + } + cfg := announcementConfig{Mode: mode, Enabled: mode != announcementOff, Title: strings.TrimSpace(c.InStr("title")), Content: strings.TrimSpace(c.InStr("content"))} + if cfg.Title == "" { + cfg.Title = "公告" + } + if utf8.RuneCountInString(cfg.Title) > 60 || utf8.RuneCountInString(cfg.Content) > 3000 { + return c.Err(400, "公告内容最多3000字,标题最多60字") + } + b, _ := json.Marshal(cfg) + if err := setSetting(c, "home_announcement", string(b)); err != nil { + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/api_postgres_test.go b/past-go/internal/handler/api_postgres_test.go index ab803ad..e259554 100644 --- a/past-go/internal/handler/api_postgres_test.go +++ b/past-go/internal/handler/api_postgres_test.go @@ -254,6 +254,42 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { post("login", "", M{"phone": "13800000001", "username": "13800000001", "password": "testpass1"}) get("check_auth", aToken, nil) get("my_info", aToken, nil) + personalUpload := uploadFile("upload", aToken, "file", nil) + personalImage := id(personalUpload, "file_id") + // Preflight uses the real current allowance and accepts already-owned duplicates. + var quotaFile model.File + if err := db.ORM.First("aFile, "id=?", personalImage).Error; err != nil { + t.Fatal(err) + } + request("upload_preflight", "POST", "", M{"size": 100, "md5": strings.Repeat("a", 32)}, 401) + post("upload_preflight", aToken, M{"size": quotaFile.Size, "md5": quotaFile.Md5}) + request("upload_preflight", "POST", aToken, M{"size": -1, "md5": "bad"}, 400) + if err := db.ORM.Model(&model.User{}).Where("id=?", aID).Update("storage_quota_mb", 1).Error; err != nil { + t.Fatal(err) + } + post("upload_preflight", aToken, M{"size": 1024*1024 - quotaFile.Size, "md5": strings.Repeat("a", 32)}) + blocked := request("upload_preflight", "POST", aToken, M{"size": 1024 * 1024, "md5": strings.Repeat("a", 32)}, 403) + if store.ToInt(blocked["code"]) != 10001 { + t.Fatalf("quota error code missing: %v", blocked) + } + // A dishonest client can pass advisory metadata, but actual upload must reject + // its different body without creating a file or granting ownership. + post("upload_preflight", aToken, M{"size": quotaFile.Size, "md5": quotaFile.Md5}) + var quotaPNG bytes.Buffer + png.Encode("aPNG, image.NewRGBA(image.Rect(0, 0, 3, 3))) + oversizedBody := append(quotaPNG.Bytes(), make([]byte, 2*1024*1024)...) + if result := uploader.SaveRawFor(oversizedBody, "png", aID); result.Code != 10001 { + t.Fatalf("forged preflight bypassed actual quota: %+v", result) + } + if err := db.ORM.Model(&model.User{}).Where("id=?", aID).Update("storage_quota_mb", 0).Error; err != nil { + t.Fatal(err) + } + personalDiary := id(post("diary_add", aToken, M{"content": "Personal diary", "image_file_ids": []string{personalImage}}), "id") + get("diary_detail", aToken, M{"id": personalDiary}) + request("diary_detail", "GET", otherToken, M{"id": personalDiary}, 404) + personalMemory := id(post("timeline_add", aToken, M{"title": "Personal memory", "event_date": today(), "bg_image_file_id": personalImage}), "id") + get("timeline_detail", aToken, M{"id": personalMemory}) + request("timeline_detail", "GET", otherToken, M{"id": personalMemory}, 404) // Personal albums work before binding and remain scoped to the creator. personalAlbum := id(post("album_add", aToken, M{"title": "Personal album"}), "id") personalFile := id(uploadFile("upload", aToken, "file", nil), "file_id") @@ -294,6 +330,7 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { t.Fatalf("partner cannot view pre-binding photos: %v", got) } post("photo_update", bToken, M{"id": personalPhoto, "taken_at": today(), "location": "Shared now", "activity": "Partner edit"}) + request("account_deletion_preview", "POST", aToken, nil, 409) post("update_couple_info", aToken, M{"love_start_date": "2026-01-01", "love_story": "story"}) diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}}) diaryID := id(diary, "id") @@ -343,6 +380,24 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { post("pet_equip", aToken, M{"item_key": "collar_basic", "equip": 1}) post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "notify"}) post("unsubscribe_template", aToken, M{"template_type": "notify"}) + post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "checkin"}) + sharedNotify := get("my_subscribe_templates", aToken, nil) + if sharedNotify["templates"].(M)["notify"] != "test-template" { + t.Fatalf("subscription not shared: %v", sharedNotify) + } + post("unsubscribe_template", aToken, M{"template_type": "checkin"}) + if _, err := db.Exec("INSERT INTO subscribe_templates (user_id,template_type,template_id) VALUES (?,?,?)", aID, "checkin", "test-template"); err != nil { + t.Fatal(err) + } + legacy := get("my_subscribe_templates", aToken, nil) + if legacy["templates"].(M)["notify"] != "test-template" { + t.Fatalf("existing checkin authorization lost: %v", legacy) + } + post("unsubscribe_template", aToken, M{"template_type": "notify"}) + if len(get("my_subscribe_templates", aToken, nil)["templates"].([]any)) != 0 { + t.Fatal("shared authorization not removed") + } + post("admin_login", "", M{"username": "past", "password": "past"}) uploadFile("upload", aToken, "file", M{"type": "diary"}) uploadFile("upload_avatar", aToken, "avatar", nil) @@ -407,7 +462,7 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { } request(action, "GET", otherToken, args, 0) } - request("diary_update", "POST", otherToken, M{"id": diaryID, "title": "intrusion", "content": "bad"}, 400) + request("diary_update", "POST", otherToken, M{"id": diaryID, "title": "intrusion", "content": "bad"}, 404) detail := get("diary_detail", aToken, M{"id": diaryID}) if strings.Contains(fmt.Sprint(detail), "intrusion") { t.Fatal("foreign account overwrote diary") @@ -460,10 +515,10 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { foreignDiary := post("diary_add", aToken, M{"title": "Private A", "content": "private", "diary_date": today()}) foreignID := id(foreignDiary, "id") request("diary_detail", "GET", otherToken, M{"id": foreignID}, 404) - post("diary_update", otherToken, M{"id": foreignID, "title": "intrusion", "content": "intrusion"}) + request("diary_update", "POST", otherToken, M{"id": foreignID, "title": "intrusion", "content": "intrusion"}, 404) post("diary_delete", otherToken, M{"id": foreignID}) var protected model.Diary - if err := db.ORM.First(&protected, "id=?", foreignID).Error; err != nil || protected.Title != "Private A" || (protected.CreatedBy == nil || *protected.CreatedBy != aID) || protected.CoupleId != cid { + if err := db.ORM.First(&protected, "id=?", foreignID).Error; err != nil || protected.Title != "Private A" || (protected.CreatedBy == nil || *protected.CreatedBy != aID) || (protected.CoupleId == nil || *protected.CoupleId != cid) { t.Fatalf("cross-couple write/delete corrupted data: %+v %v", protected, err) } @@ -488,17 +543,15 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { fileRequest := httptest.NewRequest("GET", store.FileAccessPath(retainedFile.Id), nil) fileResponse := httptest.NewRecorder() engine.ServeHTTP(fileResponse, fileRequest) - if fileResponse.Code != 307 { - t.Fatalf("file access endpoint: %d", fileResponse.Code) + if fileResponse.Code != 200 || fileResponse.Body.Len() == 0 || fileResponse.Header().Get("Cache-Control") != "private, max-age=3600" { + t.Fatalf("file cache response: %d %v", fileResponse.Code, fileResponse.Header()) } - realClient := &http.Client{Transport: oldTransport, Timeout: 15 * time.Second} - download, err := realClient.Get(fileResponse.Header().Get("Location")) - if err != nil { - t.Fatal(err) - } - download.Body.Close() - if download.StatusCode != 200 { - t.Fatalf("shared object download failed: %d", download.StatusCode) + cachedReq := httptest.NewRequest("GET", store.FileAccessPath(retainedFile.Id), nil) + cachedReq.Header.Set("If-None-Match", fileResponse.Header().Get("ETag")) + cached := httptest.NewRecorder() + engine.ServeHTTP(cached, cachedReq) + if cached.Code != 304 || cached.Body.Len() != 0 { + t.Fatalf("conditional image request: %d", cached.Code) } // Cold-period cancellation must retain the active relationship and inventory. post("unbind_request", aToken, nil) @@ -549,6 +602,110 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { t.Fatalf("deletion affected unrelated couple: %+v %v", survivor, err) } + // Announcement administration is protected and disabled text is not exposed. + request("admin_announcement", "POST", aToken, M{"enabled": true, "title": "test", "content": "notice"}, 401) + post("admin_announcement", "admin", M{"enabled": true, "title": "公告", "content": "Test announcement"}) + get("admin_announcement", "admin", nil) + notice := get("announcement", aToken, nil) + if notice["enabled"] != true || notice["content"] != "Test announcement" { + t.Fatalf("announcement missing: %v", notice) + } + post("admin_announcement", "admin", M{"enabled": false, "title": "公告", "content": "Retained draft"}) + if n := get("announcement", aToken, nil); n["enabled"] != false || n["content"] != "" || n["revision"] == notice["revision"] { + t.Fatalf("announcement disable failed: %v", n) + } + post("smoking_card_visible", aToken, M{"visible": 0}) + if me := get("check_auth", aToken, nil)["user"].(M); store.ToInt(me["smokingCardVisible"]) != 0 { + t.Fatalf("preference not persisted: %v", me) + } + post("smoking_card_visible", aToken, M{"visible": 1}) + post("admin_announcement", "admin", M{"mode": "always", "title": "Always", "content": "Every visit"}) + if n := get("announcement", aToken, nil); n["mode"] != "always" || n["enabled"] != true { + t.Fatalf("always mode failed: %v", n) + } + post("admin_announcement", "admin", M{"mode": "login_once", "title": "Once", "content": "Each login"}) + if n := get("announcement", aToken, nil); n["mode"] != "login_once" { + t.Fatalf("login mode failed: %v", n) + } + post("admin_announcement", "admin", M{"mode": "always", "title": "Blank", "content": " "}) + if n := get("announcement", aToken, nil); n["enabled"] != false { + t.Fatalf("blank announcement enabled: %v", n) + } + post("admin_announcement", "admin", M{"mode": "off", "title": "Draft", "content": "Hidden draft"}) + if n := get("admin_announcement", "admin", nil); n["content"] != "Hidden draft" { + t.Fatal("disabled draft was lost") + } + request("admin_announcement", "POST", "admin", M{"mode": "invalid", "content": "bad"}, 400) + // Account deletion includes unsaved uploads and previously soft-deleted content. + deleteUser := post("register", "", M{"phone": "13800000007", "password": "deletepass6", "nickname": "Disposable"}) + deleteToken := store.ToStr(deleteUser["token"]) + otherID := store.ToStr(deleteUser["user_id"]) + ownFile := id(uploadFile("upload", deleteToken, "file", nil), "file_id") + var uniquePNG bytes.Buffer + uniqueImage := image.NewRGBA(image.Rect(0, 0, 16, 16)) + draw.Draw(uniqueImage, uniqueImage.Bounds(), image.NewUniform(color.RGBA{R: 201, G: 92, B: 111, A: 255}), image.Point{}, draw.Src) + png.Encode(&uniquePNG, uniqueImage) + uniqueUpload := uploader.SaveRawFor(uniquePNG.Bytes(), "png", otherID) + uniqueURL, _ := uploader.PresignedURL(context.Background(), uniqueUpload.FileId) + if uniqueUpload.Error != "" { + t.Fatal(uniqueUpload.Error) + } + ownedAlbum := id(post("album_add", deleteToken, M{"title": "Delete personal"}), "id") + post("photo_add", deleteToken, M{"album_id": ownedAlbum, "file_id": ownFile}) + post("photo_add", deleteToken, M{"album_id": ownedAlbum, "file_id": uniqueUpload.FileId}) + deletedDiary := id(post("diary_add", deleteToken, M{"content": "Delete this diary", "image_file_ids": []string{ownFile}}), "id") + post("diary_delete", deleteToken, M{"id": deletedDiary}) + request("account_delete", "POST", deleteToken, M{}, 400) + preview := post("account_deletion_preview", deleteToken, nil) + post("album_add", deleteToken, M{"title": "Added after preview"}) + confirm := M{"confirmed": true, "confirmation_text": "注销并删除全部数据", "confirmation_token": preview["confirmation_token"]} + request("account_delete", "POST", deleteToken, confirm, 409) + preview = post("account_deletion_preview", deleteToken, nil) + confirm["confirmation_token"] = preview["confirmation_token"] + post("account_delete", deleteToken, confirm) + request("album_list", "GET", deleteToken, nil, 401) + for _, target := range []any{&model.User{}, &model.UserToken{}, &model.Album{}, &model.Photo{}, &model.Diary{}, &model.FileUpload{}, &model.RecordFile{}} { + var n int64 + q := db.ORM.Unscoped().Model(target) + switch target.(type) { + case *model.User: + q = q.Where("id=?", otherID) + case *model.Album, *model.Photo, *model.Diary: + q = q.Where("created_by=?", otherID) + case *model.RecordFile: + q = q.Where("owner_id=?", deletedDiary) + default: + q = q.Where("user_id=?", otherID) + } + if err := q.Count(&n).Error; err != nil || n != 0 { + t.Fatalf("account data left behind %T: %d %v", target, n, err) + } + } + // Failure to reach object storage must leave a durable deletion job for retry. + var pending int64 + if err := db.ORM.Model(&model.FileDeletion{}).Where("file_id=?", uniqueUpload.FileId).Count(&pending).Error; err != nil || pending != 1 { + t.Fatalf("missing cleanup job: %d %v", pending, err) + } + if err := uploader.CleanupDeletedFiles(); err != nil { + t.Fatal(err) + } + var gone int64 + db.ORM.Unscoped().Model(&model.File{}).Where("id=?", uniqueUpload.FileId).Count(&gone) + if gone != 0 { + t.Fatal("exclusive file metadata remains") + } + if response, err := uploader.HTTPClient.Get(uniqueURL); err != nil { + t.Fatal(err) + } else { + response.Body.Close() + if response.StatusCode == 200 { + t.Fatal("exclusive object remains in MinIO") + } + } + + if err := db.ORM.First(&model.File{}, "id=?", ownFile).Error; err != nil { + t.Fatalf("deduplicated file of another user deleted: %v", err) + } // Every registered action must at least exercise its real authentication gate. actions := router.Actions() sort.Strings(actions) diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index 6acc19d..4ec619d 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -6,6 +6,7 @@ import ( "past/internal/httpapi" "past/internal/store" + "past/internal/wechat" ) func registerCheckin(r *httpapi.Router) { @@ -300,15 +301,10 @@ func checkinDo(c *httpapi.Ctx) error { c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id) } title := item.Str("title") - actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "") + actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", store.ToStr(note)) c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note) if partnerID := partnerUserId(c.DB, uid); partnerID != "" { - c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ - "thing1": "打卡「" + mbTruncate(title, 20) + "」", - "thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak), - "date3": td, - "thing4": "点击查看详情", - }, "pages/punch/punch", actID, uid) + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", wechat.CheckinMessage(title, store.ToStr(note), nowS), "pages/punch/punch", actID, uid) } return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ}) } diff --git a/past-go/internal/handler/common.go b/past-go/internal/handler/common.go index 8c9d649..da9a365 100644 --- a/past-go/internal/handler/common.go +++ b/past-go/internal/handler/common.go @@ -40,6 +40,10 @@ func Register(r *httpapi.Router, uploader *upload.Uploader) { registerPet(r) registerAdmin(r) registerStorage(r) + r.Handle("announcement", announcement) + r.Handle("admin_announcement", adminAnnouncement) + r.Handle("account_deletion_preview", accountDeletionPreview) + r.Handle("account_delete", accountDelete) } func now() string { return time.Now().Format("2006-01-02 15:04:05") } @@ -90,6 +94,9 @@ func generateCoupleSpaceId(coupleID string) string { // logActivity 对应 logActivity(),返回 activity id func logActivity(db store.Querier, coupleID, userID string, actionType, description, detail string) string { + if coupleID == "" { + return "" + } id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())", coupleID, userID, actionType, description, detail) return id @@ -405,3 +412,10 @@ func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any { // randInt 对应 random_int(min, max) func randInt(min, max int) int { return min + rand.Intn(max-min+1) } + +func nullableCoupleID(id string) any { + if id == "" { + return nil + } + return id +} diff --git a/past-go/internal/handler/diary.go b/past-go/internal/handler/diary.go index 0891024..7dea19a 100644 --- a/past-go/internal/handler/diary.go +++ b/past-go/internal/handler/diary.go @@ -58,7 +58,7 @@ func registerDiary(r *httpapi.Router) { } func diaryList(c *httpapi.Ctx) error { - ids := coupleUserIds(c, "") + ids := albumUserIds(c) if len(ids) == 0 { demo := demoData("diary") return c.OK(M{"items": demo, "total": len(demo)}) @@ -109,10 +109,7 @@ func diaryAdd(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - cid, err := requireCoupleID(c) - if err != nil { - return err - } + cid := c.CoupleID() uid := c.UID() diaryDate := diaryDateOrFallback(c.In("diary_date"), today()) images := inOr(c, "images", []any{}) @@ -129,7 +126,7 @@ func diaryAdd(c *httpapi.Ctx) error { return err } id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid) + nullableCoupleID(cid), title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid) if err != nil { return err } @@ -166,10 +163,13 @@ func diaryUpdate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { + ids := albumUserIds(c) + if item, err := c.DB.QueryOne("SELECT id FROM diaries WHERE id=? AND created_by IN (?,?)", c.In("id"), ids[0], ids[1]); err != nil { return err + } else if item == nil { + return c.Err(404, "日记不存在或无权编辑") } + var err error images := inOr(c, "images", []any{}) // 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期; // 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。 @@ -195,10 +195,7 @@ func diaryDelete(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { return err } @@ -218,10 +215,8 @@ func diaryDetail(c *httpapi.Ctx) error { if id == "" { return c.Err(400, "缺少日记ID") } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error uid := c.UID() item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1]) if err != nil { @@ -247,10 +242,8 @@ func diaryCommentList(c *httpapi.Ctx) error { if diaryID == "" { return c.Err(400, "缺少日记ID") } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error suffix, window := listWindow(c) comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...) if err != nil { diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go index 29973a7..8ac5be5 100644 --- a/past-go/internal/handler/misc.go +++ b/past-go/internal/handler/misc.go @@ -92,8 +92,15 @@ func bgKeys(cid string) (string, string) { return "bg_global", "bgver_global" } +func personalBgKeys(c *httpapi.Ctx) (string, string) { + if c.CoupleID() != "" { + return bgKeys(c.CoupleID()) + } + return "bg_user_" + c.UID(), "bgver_user_" + c.UID() +} + func getBgConfig(c *httpapi.Ctx) error { - bgKey, verKey := bgKeys(c.CoupleID()) + bgKey, verKey := personalBgKeys(c) img, _ := settingValue(c, bgKey) ver, _ := settingValue(c, verKey) return c.OK(M{"image": img, "version": store.ToInt(ver)}) @@ -103,11 +110,7 @@ func setBgImage(c *httpapi.Ctx) error { if c.Method != "POST" { return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) } - cid := c.CoupleID() - if cid == "" { - return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound) - } - bgKey, verKey := bgKeys(cid) + bgKey, verKey := personalBgKeys(c) nextVersion := func() int64 { if old, ok := settingValue(c, verKey); ok { return store.ToInt(old) + 1 @@ -152,11 +155,7 @@ func deleteBgImage(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - cid, err := requireCoupleID(c) - if err != nil { - return err - } - bgKey, verKey := bgKeys(cid) + bgKey, verKey := personalBgKeys(c) if _, err := c.DB.Exec("DELETE FROM settings WHERE key = ? OR key = ?", bgKey, verKey); err != nil { return err } @@ -258,6 +257,9 @@ func subscribeTemplate(c *httpapi.Ctx) error { } uid := c.UID() templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id") + if templateType == "checkin" { + templateType = "notify" + } if !store.Truthy(templateType) || !store.Truthy(templateID) { return c.Err(400, "缺少参数") } @@ -275,10 +277,13 @@ func unsubscribeTemplate(c *httpapi.Ctx) error { return err } templateType := c.InTrim("template_type") + if templateType == "checkin" { + templateType = "notify" + } if !store.Truthy(templateType) { return c.Err(400, "缺少参数") } - if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", c.UID(), templateType); err != nil { + if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type IN (?, 'checkin')", c.UID(), templateType); err != nil { return err } return c.OK(M{"success": true}) @@ -294,7 +299,9 @@ func mySubscribeTemplates(c *httpapi.Ctx) error { } out := M{} for _, r := range rs { - out[r.Str("template_type")] = r["template_id"] + if r.Str("template_id") == c.App.Wechat.NotifyTemplateID { + out["notify"] = r["template_id"] + } } return c.OK(M{"templates": out}) } diff --git a/past-go/internal/handler/storage.go b/past-go/internal/handler/storage.go index 7ef7bc5..65a1d91 100644 --- a/past-go/internal/handler/storage.go +++ b/past-go/internal/handler/storage.go @@ -31,6 +31,7 @@ const ErrStorageQuotaExceeded = 10001 func registerStorage(r *httpapi.Router) { r.Handle("storage_info", storageInfo) + r.Handle("upload_preflight", uploadPreflight) r.Handle("wechat_pay_notify", wechatPayNotify) r.Handle("storage_order_status", storageOrderStatus) r.Handle("storage_create_order", storageOrderCreate) diff --git a/past-go/internal/handler/timeline.go b/past-go/internal/handler/timeline.go index e66e39e..03525f6 100644 --- a/past-go/internal/handler/timeline.go +++ b/past-go/internal/handler/timeline.go @@ -42,7 +42,7 @@ func checkTextOr400(c *httpapi.Ctx, text string) error { } func timelineList(c *httpapi.Ctx) error { - ids := coupleUserIds(c, "") + ids := albumUserIds(c) if len(ids) == 0 { return demoListResponse(c, "timeline", nil) } @@ -66,16 +66,13 @@ func timelineAdd(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - cid, err := requireCoupleID(c) - if err != nil { - return err - } + cid := c.CoupleID() uid := c.UID() if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { return err } id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - cid, c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""), + nullableCoupleID(cid), c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""), jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid) if err != nil { return err @@ -97,10 +94,13 @@ func timelineUpdate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { + ids := albumUserIds(c) + if item, err := c.DB.QueryOne("SELECT id FROM timeline WHERE id=? AND created_by IN (?,?)", c.In("id"), ids[0], ids[1]); err != nil { return err + } else if item == nil { + return c.Err(404, "记忆日不存在或无权编辑") } + var err error if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { return err } @@ -117,10 +117,7 @@ func timelineDelete(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { return err } @@ -131,10 +128,8 @@ func timelineDetail(c *httpapi.Ctx) error { if err := c.RequireGET(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error id := c.Q("id") if id == "" || id == "0" { return c.Err(400, "缺少ID") diff --git a/past-go/internal/handler/upload_preflight.go b/past-go/internal/handler/upload_preflight.go new file mode 100644 index 0000000..8ce0332 --- /dev/null +++ b/past-go/internal/handler/upload_preflight.go @@ -0,0 +1,43 @@ +package handler + +import ( + "encoding/hex" + "errors" + "strings" + + "past/internal/httpapi" + "past/internal/model" + "past/internal/store" +) + +// This is advisory: actual upload recalculates the digest and byte count under +// the shared-space quota lock before writing anything to MinIO. +func uploadPreflight(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + size := c.InInt("size") + digest := strings.ToLower(c.InTrim("md5")) + raw, err := hex.DecodeString(digest) + if size <= 0 || err != nil || len(raw) != 16 { + return c.Err(400, "文件大小或MD5无效") + } + if c.App.Files == nil { + return c.Err(503, "文件服务暂不可用") + } + if size > c.App.Files.MaxSize { + return c.Err(413, "文件超过上传大小限制") + } + file := model.File{Size: size} + err = c.DB.ORM.WithContext(c.R.Context()).Where("bucket=? AND md5=? AND size=?", c.App.Files.Bucket, digest, size).Find(&file).Error + if err != nil { + return err + } + if err := store.CheckFileQuota(c.DB.ORM, c.UID(), file); err != nil { + if errors.Is(err, store.ErrUploadQuota) { + return c.ErrCode(403, "剩余存储空间不足,请购买更大容量的套餐后继续上传", ErrStorageQuotaExceeded) + } + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/user.go b/past-go/internal/handler/user.go index 632c87e..daed26c 100644 --- a/past-go/internal/handler/user.go +++ b/past-go/internal/handler/user.go @@ -279,7 +279,8 @@ func wechatLogin(c *httpapi.Ctx) error { } return c.OK(M{ "success": true, "is_new": false, - "user_id": user["id"], "display_id": user["user_id"], + "data_revision": c.DataRevisionFor(uid, user.Str("couple_id")), + "user_id": user["id"], "display_id": user["user_id"], "couple_id": user["couple_id"], "nickname": user["nickname"], "avatar": user.Str("avatar"), "gender": user.Str("gender"), "birthday": user.Str("birthday"), "region": user.Str("region"), @@ -305,7 +306,8 @@ func wechatLogin(c *httpapi.Ctx) error { c.SaveUserToken(newID, token) return c.OK(M{ "success": true, "is_new": true, - "user_id": fmt.Sprint(newID), "display_id": displayID, + "data_revision": c.DataRevisionFor(fmt.Sprint(newID), ""), + "user_id": fmt.Sprint(newID), "display_id": displayID, "couple_id": nil, "nickname": defaultNick, "avatar": "", "gender": "", "birthday": "", "region": region, "token": token, @@ -330,11 +332,11 @@ func checkAuth(c *httpapi.Ctx) error { } } } - user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid) + user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id, smoking_card_visible FROM users WHERE id = ?", uid) if err != nil { return err } - return c.OK(M{"logged_in": true, "user": user}) + return c.OK(M{"logged_in": true, "user": user, "data_revision": c.DataRevision()}) } func logout(c *httpapi.Ctx) error { diff --git a/past-go/internal/httpapi/auth.go b/past-go/internal/httpapi/auth.go index 6730c62..5310c3b 100644 --- a/past-go/internal/httpapi/auth.go +++ b/past-go/internal/httpapi/auth.go @@ -92,6 +92,7 @@ func (c *Ctx) requireUserLogin() error { if user == nil { return c.Err(404, "用户不存在") } + c.G.Set("revisionCoupleID", user.Str("couple_id")) needRecord := false if user.IsNull("last_login_at") || user.Str("last_login_at") == "" { needRecord = true diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go index 1599288..b35559d 100644 --- a/past-go/internal/httpapi/ctx.go +++ b/past-go/internal/httpapi/ctx.go @@ -70,6 +70,7 @@ func (c *Ctx) JSON(code int, v any) error { return errResponded } c.responded = true + c.publishDataChange(code, v) if c.uidResolved && c.uid != "" && c.coupleResolved { c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID)) c.G.Header("X-User-ID", fmt.Sprint(c.uid)) diff --git a/past-go/internal/httpapi/revision.go b/past-go/internal/httpapi/revision.go new file mode 100644 index 0000000..b3c98a3 --- /dev/null +++ b/past-go/internal/httpapi/revision.go @@ -0,0 +1,90 @@ +package httpapi + +import ( + "context" + "crypto/sha256" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +var revisionMutation = regexp.MustCompile(`^(update_(profile|partner_profile|couple_info|location|ip_location)|upload(_avatar|_partner_avatar)?|timeline_(add|update|delete)|album_(add|update|delete)|photo_(add|update|delete|upload)|set_album_cover|diary_(add|update|delete|comment_add|comment_delete|like|favorite)|songs_(add|update|delete)|checkin_(add|do|uncheck|toggle|delete)|smoking_(action|card_visible)|pet_(adopt|daily_action|rename|equip)|period_(add|update|delete|visible_set|source_select|daily_save|sex_add|sex_delete)|set_bg_image|delete_bg_image|respond_invitation|accept_share_invite|unbind_(request|respond|cancel|couple)|account_delete|wechat_pay_notify|admin_(announcement|storage_settings|storage_user_update|update_user|delete_user|delete_content|set_global_bg|clear_global_bg|clear_couple_bg|force_unbind|restore_couple|delete_couple_data))$`) + +func (c *Ctx) revisionKeys() []string { + cid := c.G.GetString("revisionCoupleID") + if cid == "" { + cid = c.coupleID + } + return c.revisionKeysFor(c.uid, cid) +} +func (c *Ctx) revisionKeysFor(uid, cid string) []string { + prefix := c.App.Wechat.KeyPrefix + "data-revision:" + keys := []string{prefix + "global"} + if uid != "" { + keys = append(keys, prefix+"user:"+uid) + } + if cid != "" { + keys = append(keys, prefix+"couple:"+cid) + } + return keys +} + +// DataRevision is shared by a user and their partner, plus global admin changes. +// Redis failure leaves the bounded client TTL as a fallback, never failing data writes. +func (c *Ctx) DataRevision() string { + return c.DataRevisionFor(c.uid, c.G.GetString("revisionCoupleID")) +} +func (c *Ctx) DataRevisionFor(uid, cid string) string { + if c.App == nil || c.App.Wechat == nil || c.App.Wechat.Redis == nil { + return "" + } + ctx, cancel := context.WithTimeout(c.R.Context(), time.Second) + defer cancel() + values, err := c.App.Wechat.Redis.MGet(ctx, c.revisionKeysFor(uid, cid)...).Result() + if err != nil { + return "" + } + return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprint(values)))) +} + +func (c *Ctx) publishDataChange(code int, value any) { + if c.App == nil || c.App.Wechat == nil || c.App.Wechat.Redis == nil || code < 200 || code >= 300 { + return + } + if payload, ok := value.(M); ok { + if payload["success"] == false || payload["error"] != nil { + return + } + } + if !revisionMutation.MatchString(c.Action) { + return + } + if c.Method != "POST" { + return + } + keys := c.revisionKeys() + // Relationship and administrative changes may affect a second user outside + // the initiating user's old scope. A global version keeps those clients safe. + if strings.HasPrefix(c.Action, "admin_") || strings.Contains(c.Action, "unbind") || c.Action == "respond_invitation" || c.Action == "accept_share_invite" || c.Action == "wechat_pay_notify" { + keys = keys[:1] + } else { + keys = keys[1:] + } + if len(keys) == 0 { + return + } + ctx, cancel := context.WithTimeout(c.R.Context(), time.Second) + defer cancel() + pipe := c.App.Wechat.Redis.TxPipeline() + version := uuid.NewString() + for _, key := range keys { + pipe.Set(ctx, key, version, 0) + } + if _, err := pipe.Exec(ctx); err != nil { + zap.S().Warnw("data revision update failed", "action", c.Action, "err", err) + } +} diff --git a/past-go/internal/httpapi/revision_test.go b/past-go/internal/httpapi/revision_test.go new file mode 100644 index 0000000..38b295a --- /dev/null +++ b/past-go/internal/httpapi/revision_test.go @@ -0,0 +1,49 @@ +package httpapi + +import ( + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "net/http/httptest" + "past/internal/wechat" + "testing" +) + +func TestRevisionScopesAndSuccessfulWrites(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer client.Close() + makeCtx := func(uid, cid, action string) *Ctx { + g, _ := gin.CreateTestContext(httptest.NewRecorder()) + g.Set("revisionCoupleID", cid) + return &Ctx{G: g, R: httptest.NewRequest("POST", "/api/"+action, nil), Method: "POST", Action: action, uid: uid, App: &App{Wechat: &wechat.Client{Redis: client, KeyPrefix: "test:"}}} + } + a, b, other := makeCtx("a", "couple", "diary_add"), makeCtx("b", "couple", ""), makeCtx("other", "elsewhere", "") + va, vb, vo := a.DataRevision(), b.DataRevision(), other.DataRevision() + a.publishDataChange(403, M{"error": "denied"}) + if a.DataRevision() != va { + t.Fatal("failed write changed revision") + } + a.publishDataChange(200, M{"success": false}) + if a.DataRevision() != va { + t.Fatal("business rejection changed revision") + } + a.publishDataChange(200, M{"success": true}) + if a.DataRevision() == va || b.DataRevision() == vb { + t.Fatal("user and partner did not see committed mutation") + } + if other.DataRevision() != vo { + t.Fatal("unrelated couple was invalidated") + } + before := a.DataRevision() + a.Action = "diary_list" + a.publishDataChange(200, M{"items": []any{}}) + if a.DataRevision() != before { + t.Fatal("read changed revision") + } + admin := makeCtx("", "", "admin_announcement") + admin.publishDataChange(200, M{"success": true}) + if a.DataRevision() == before || other.DataRevision() == vo { + t.Fatal("admin change was not visible globally") + } +} diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go index 4fbad99..c5de59e 100644 --- a/past-go/internal/httpapi/router.go +++ b/past-go/internal/httpapi/router.go @@ -17,6 +17,8 @@ import ( "time" "github.com/gin-gonic/gin" + "past/internal/model" + "past/internal/store" "past/internal/upload" ) @@ -57,7 +59,7 @@ func (r *Router) Actions() []string { var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login", "check_invite_code", "register_by_invite", "check_auth", "wechat_pay_notify") -var adminActions = set("admin_files", "admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders") +var adminActions = set("admin_announcement", "admin_files", "admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders") func set(items ...string) map[string]bool { m := make(map[string]bool, len(items)) @@ -88,17 +90,56 @@ func (r *Router) Engine(debugMode bool) *gin.Engine { // 简洁 API 路径;旧入口保留给已发布客户端和管理后台。 e.GET("/api/files/:fileId", func(c *gin.Context) { - if r.app.Files == nil { - c.Status(503) - return - } - signed, err := r.app.Files.PresignedURL(c.Request.Context(), c.Param("fileId")) - if err != nil { - c.JSON(404, gin.H{"error": "文件不存在"}) - return - } c.Header("Cache-Control", "no-store") - c.Redirect(http.StatusTemporaryRedirect, signed) + if r.app.Files == nil || !store.ValidUUID(c.Param("fileId")) { + c.Status(404) + return + } + var file model.File + if err := r.app.DB.ORM.WithContext(c.Request.Context()).First(&file, "id=?", c.Param("fileId")).Error; err != nil { + c.Status(404) + return + } + etag := `"` + file.Md5 + `"` + c.Header("ETag", etag) + c.Header("Cache-Control", "private, max-age=3600") + c.Header("X-Content-Type-Options", "nosniff") + if c.GetHeader("If-None-Match") == etag { + c.Status(http.StatusNotModified) + return + } + signed, err := r.app.Files.PresignedURL(c.Request.Context(), file.Id) + if err != nil { + c.Header("Cache-Control", "no-store") + c.Status(502) + return + } + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, signed, nil) + if err != nil { + c.Status(502) + return + } + if v := c.GetHeader("Range"); v != "" { + req.Header.Set("Range", v) + } + response, err := r.app.Files.HTTPClient.Do(req) + if err != nil { + c.Header("Cache-Control", "no-store") + c.Status(502) + return + } + defer response.Body.Close() + if response.StatusCode != 200 && response.StatusCode != 206 { + c.Header("Cache-Control", "no-store") + c.Status(502) + return + } + if v := response.Header.Get("Content-Range"); v != "" { + c.Header("Content-Range", v) + } + c.Header("Accept-Ranges", "bytes") + c.Header("Content-Disposition", "inline") + c.DataFromReader(response.StatusCode, response.ContentLength, file.ContentType, response.Body, nil) }) e.Any("/api", r.serveAPI) e.Any("/api/", r.serveAPI) diff --git a/past-go/internal/model/files.go b/past-go/internal/model/files.go index 5ef0678..8430f7e 100644 --- a/past-go/internal/model/files.go +++ b/past-go/internal/model/files.go @@ -37,3 +37,9 @@ type RecordFile struct { FileId string `gorm:"type:uuid;not null;index" json:"file_id"` File File `gorm:"foreignKey:FileId;references:Id;constraint:OnDelete:RESTRICT" json:"-"` } + +// Durable queue: object deletion is retried if MinIO is temporarily unavailable. +type FileDeletion struct { + BaseModel + FileId string `gorm:"type:uuid;not null;uniqueIndex" json:"file_id"` +} diff --git a/past-go/internal/model/models.go b/past-go/internal/model/models.go index a8627d7..b5ac1b3 100644 --- a/past-go/internal/model/models.go +++ b/past-go/internal/model/models.go @@ -99,7 +99,7 @@ type InviteLink struct { type Timeline struct { BaseModel - CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"` + CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"` Title string `gorm:"column:title;type:varchar(255);not null" json:"title"` Content string `gorm:"column:content;type:text" json:"content"` EventDate string `gorm:"column:event_date;type:varchar(20);not null" json:"event_date"` @@ -149,7 +149,7 @@ type Photo struct { type Diary struct { BaseModel - CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"` + CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"` Title string `gorm:"column:title;type:varchar(255);not null" json:"title"` Content string `gorm:"column:content;type:text" json:"content"` Mood string `gorm:"column:mood;type:varchar(20);default:'😊'" json:"mood"` @@ -497,7 +497,7 @@ type SmokingEntry struct { // Models is the complete AutoMigrate registry. func Models() []any { - return []any{&File{}, &FileUpload{}, &RecordFile{}, &UnbindingFileCleanup{}, &User{}, &Couple{}, &Invitation{}, &UnbindRequest{}, &InviteLink{}, &Timeline{}, &Album{}, &Photo{}, &Diary{}, &Song{}, &Checkin{}, &CheckinLog{}, &SmokingAccount{}, &SmokingLog{}, &PeriodRecord{}, &PeriodDailyRecord{}, &PeriodSexRecord{}, &Setting{}, &DiaryComment{}, &SubscribeTemplate{}, &ActivityLog{}, &Admin{}, &UserToken{}, &SubscribeMessageLog{}, &NotifyLog{}, &Pet{}, &PetDaily{}, &PetAchievement{}, &PetItem{}, &PetDynamic{}, &StorageOrder{}, &StoragePlan{}, &StorageEntitlement{}, &DiaryLike{}, &DiaryFavorite{}, &SmokingWallet{}, &SmokingEntry{}} + return []any{&FileDeletion{}, &File{}, &FileUpload{}, &RecordFile{}, &UnbindingFileCleanup{}, &User{}, &Couple{}, &Invitation{}, &UnbindRequest{}, &InviteLink{}, &Timeline{}, &Album{}, &Photo{}, &Diary{}, &Song{}, &Checkin{}, &CheckinLog{}, &SmokingAccount{}, &SmokingLog{}, &PeriodRecord{}, &PeriodDailyRecord{}, &PeriodSexRecord{}, &Setting{}, &DiaryComment{}, &SubscribeTemplate{}, &ActivityLog{}, &Admin{}, &UserToken{}, &SubscribeMessageLog{}, &NotifyLog{}, &Pet{}, &PetDaily{}, &PetAchievement{}, &PetItem{}, &PetDynamic{}, &StorageOrder{}, &StoragePlan{}, &StorageEntitlement{}, &DiaryLike{}, &DiaryFavorite{}, &SmokingWallet{}, &SmokingEntry{}} } // LegacyTables names logical tables used by existing report queries. diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go index d0dfdfb..bf1bb50 100644 --- a/past-go/internal/notify/notify.go +++ b/past-go/internal/notify/notify.go @@ -74,11 +74,11 @@ func Once(db *store.DB, wx Sender, force bool) { for _, action := range actionTypes { args = append(args, action) } - query := `SELECT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a + query := `SELECT DISTINCT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a JOIN users u ON u.id=a.user_id JOIN couples cp ON cp.id=a.couple_id AND cp.status='active' AND u.couple_id=cp.id JOIN users recipient ON recipient.id=CASE WHEN cp.user1_id=u.id THEN cp.user2_id ELSE cp.user1_id END AND recipient.couple_id=cp.id - JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type='notify' + JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type IN ('notify','checkin') WHERE a.action_type IN (` + ph + `) AND u.id IN (cp.user1_id, cp.user2_id) AND NOT EXISTS (SELECT 1 FROM notify_log nl WHERE nl.activity_id=a.id AND nl.to_user_id=recipient.id AND nl.status='success') AND NOT EXISTS (SELECT 1 FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id AND sl.status='success') @@ -136,9 +136,13 @@ func Once(db *store.DB, wx Sender, force bool) { if thing2 == "" { thing2 = thing1 } - data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"} + data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕", "event_time": a.Str("created_at")} - ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID) + templateType := "notify" + if actionType == "checkin_do" { + data = wechat.CheckinMessage(desc, a.Str("detail"), a.Str("created_at")) + } + ok = wx.SendSubscribeMessage(partnerID, templateType, data, page, activityID, userID) processed++ if ok { success++ diff --git a/past-go/internal/notify/notify_test.go b/past-go/internal/notify/notify_test.go index 50fe49a..17c55b5 100644 --- a/past-go/internal/notify/notify_test.go +++ b/past-go/internal/notify/notify_test.go @@ -11,11 +11,14 @@ import ( type testSender struct { calls int to, from string + typ string + data map[string]string } func (s *testSender) SendSubscribeMessage(to string, typ string, data map[string]string, page string, activity, from string) bool { s.calls++ s.to, s.from = to, from + s.typ, s.data = typ, data return true } @@ -35,7 +38,7 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) { {"force still checks sharing", 0, 0, true, true, 0}, } { t.Run(tt.name, func(t *testing.T) { - steps := []testdb.Step{{Match: "SELECT a.*", Check: func(query string, _ []any) error { + steps := []testdb.Step{{Match: "SELECT DISTINCT a.*", Check: func(query string, _ []any) error { for _, part := range []string{"nl.status='success'", "sl.status='success'", "cp.status='active'", "u.couple_id=cp.id", "COALESCE(u.period_visible,0)<>0", "JOIN subscribe_templates"} { if !strings.Contains(query, part) { t.Errorf("missing guard %s", part) @@ -66,3 +69,21 @@ func TestRetrySelectionAndPermissionRecheck(t *testing.T) { }) } } + +func TestCheckinRetryUsesDedicatedTemplate(t *testing.T) { + db, _ := testdb.Open(t, []testdb.Step{ + {Match: "SELECT DISTINCT a.*", Check: func(q string, _ []any) error { + if !strings.Contains(q, "st.template_type IN ('notify','checkin')") { + t.Error("retry selected wrong authorization") + } + return nil + }, Columns: []string{"id", "couple_id", "user_id", "to_user_id", "action_type", "created_at", "description", "detail"}, Rows: [][]driver.Value{{int64(9), int64(5), int64(1), int64(2), "checkin_do", "2026-11-03 20:58:00", "打卡「运动」", "今天也坚持啦"}}}, + {Match: "SELECT u.period_visible", Columns: []string{"period_visible"}, Rows: [][]driver.Value{{int64(1)}}}, + {Match: "SELECT COUNT(*) as cnt FROM subscribe_message_logs", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(1)}}}, + }) + sender := &testSender{} + Once(store.WrapSQL(db), sender, false) + if sender.calls != 1 || sender.typ != "notify" || len(sender.data) != 3 || sender.data["thing1"] != "今天也坚持啦" || sender.data["time2"] != "2026年11月03日 20:58" { + t.Fatalf("wrong retry: %+v", sender) + } +} diff --git a/past-go/internal/store/account.go b/past-go/internal/store/account.go new file mode 100644 index 0000000..3f97c9b --- /dev/null +++ b/past-go/internal/store/account.go @@ -0,0 +1,225 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "past/internal/model" + "strings" +) + +var ErrAccountBound = errors.New("请先解除情侣关系,再注销账号") +var ErrAccountChanged = errors.New("数据已发生变化,请重新确认注销统计") + +type AccountSummary struct { + Counts map[string]int64 `json:"counts"` + FileBytes int64 `json:"file_bytes"` + RecordCounts map[string]int64 `json:"record_counts"` + Revision string `json:"revision"` +} + +func accountUnbound(tx *gorm.DB, uid string, lock bool) error { + var u model.User + q := tx + if lock { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := q.First(&u, "id=?", uid).Error; err != nil { + return err + } + if u.CoupleId != nil && *u.CoupleId != "" { + return ErrAccountBound + } + var n int64 + if err := tx.Model(&model.Couple{}).Where("(user1_id=? OR user2_id=?) AND status <> 'unbound'", uid, uid).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return ErrAccountBound + } + return nil +} +func summaryIn(tx *gorm.DB, uid string) (AccountSummary, error) { + result := AccountSummary{Counts: map[string]int64{}, RecordCounts: map[string]int64{}} + for name, q := range map[string]*gorm.DB{ + "albums": tx.Unscoped().Model(&model.Album{}).Where("created_by=?", uid), + "photos": tx.Unscoped().Model(&model.Photo{}).Where("created_by=? OR album_id IN (?)", uid, tx.Unscoped().Model(&model.Album{}).Select("id").Where("created_by=?", uid)), + "diaries": tx.Unscoped().Model(&model.Diary{}).Where("created_by=?", uid), + "timeline": tx.Unscoped().Model(&model.Timeline{}).Where("created_by=?", uid), + "checkins": tx.Unscoped().Model(&model.CheckinLog{}).Where("user_id=?", uid), + "smoking": tx.Unscoped().Model(&model.SmokingEntry{}).Where("user_id=?", uid), + "period": tx.Unscoped().Model(&model.PeriodDailyRecord{}).Where("user_id=?", uid), + "files": tx.Unscoped().Model(&model.FileUpload{}).Where("user_id=?", uid).Distinct("file_id"), + } { + var count int64 + if err := q.Count(&count).Error; err != nil { + return result, err + } + result.Counts[name] = count + } + if err := tx.Unscoped().Model(&model.File{}).Where("id IN (?)", tx.Unscoped().Model(&model.FileUpload{}).Select("file_id").Where("user_id=?", uid)).Select("COALESCE(SUM(size),0)").Scan(&result.FileBytes).Error; err != nil { + return result, err + } + // Revision covers actual row data, including edits that leave counts unchanged. + hash := sha256.New() + for _, m := range model.Models() { + st := &gorm.Statement{DB: tx} + if err := st.Parse(m); err != nil { + return result, err + } + if st.Schema.Table == "past_user" || st.Schema.Table == "past_file_deletion" { + continue + } + filters := []string{} + args := []any{} + for _, f := range st.Schema.Fields { + switch f.DBName { + case "user_id", "created_by", "from_user_id", "to_user_id", "requester_id": + filters = append(filters, `"`+f.DBName+`"=?`) + args = append(args, uid) + } + } + if len(filters) == 0 { + continue + } + var digest struct { + Count int64 + Digest string + } + query := `SELECT COUNT(*) AS count,md5(COALESCE(string_agg(md5(row_to_json(t)::text),'' ORDER BY id),'')) AS digest FROM "` + st.Schema.Table + `" t WHERE ` + strings.Join(filters, " OR ") + if err := tx.Raw(query, args...).Scan(&digest).Error; err != nil { + return result, err + } + result.RecordCounts[st.Schema.Table] = digest.Count + hash.Write([]byte(st.Schema.Table)) + hash.Write([]byte(digest.Digest)) + } + result.Revision = hex.EncodeToString(hash.Sum(nil)) + return result, nil +} +func (db *DB) AccountDeletionSummary(uid string) (AccountSummary, error) { + var result AccountSummary + err := db.ORM.Transaction(func(tx *gorm.DB) error { + if err := accountUnbound(tx, uid, true); err != nil { + return err + } + var err error + result, err = summaryIn(tx, uid) + return err + }) + return result, err +} +func (db *DB) DeleteAccount(uid, revision string) error { + return db.ORM.Transaction(func(tx *gorm.DB) error { + if err := accountUnbound(tx, uid, true); err != nil { + return err + } + summary, err := summaryIn(tx, uid) + if err != nil { + return err + } + if summary.Revision != revision { + return ErrAccountChanged + } + var fileIds []string + if err := tx.Unscoped().Model(&model.FileUpload{}).Where("user_id=?", uid).Distinct("file_id").Pluck("file_id", &fileIds).Error; err != nil { + return err + } + // Remove dependent records before their parents, including previously soft-deleted data. + statements := []string{ + `DELETE FROM past_setting WHERE key IN (CONCAT('bg_user_',?),CONCAT('bgver_user_',?))`, + `UPDATE past_diary_comment SET reply_to_user_id=NULL,reply_to_username='' WHERE reply_to_user_id=?`, + `DELETE FROM past_record_file WHERE (owner_table='diaries' AND owner_id IN (SELECT id FROM past_diary WHERE created_by=?)) OR (owner_table='timeline' AND owner_id IN (SELECT id FROM past_timeline WHERE created_by=?))`, + `DELETE FROM past_diary_comment WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`, + `DELETE FROM past_diary_like WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`, + `DELETE FROM past_diary_favorite WHERE diary_id IN (SELECT id FROM past_diary WHERE created_by=?)`, + `DELETE FROM past_photo WHERE album_id IN (SELECT id FROM past_album WHERE created_by=?)`, + `DELETE FROM past_checkin_log WHERE checkin_id IN (SELECT id FROM past_checkin WHERE created_by=?)`, + `DELETE FROM past_smoking_log WHERE account_id IN (SELECT id FROM past_smoking_account WHERE user_id=?)`, + } + for _, sql := range statements { + args := []any{} + for i := 0; i < strings.Count(sql, "?"); i++ { + args = append(args, uid) + } + if err := tx.Exec(sql, args...).Error; err != nil { + return err + } + } + for _, m := range model.Models() { + st := &gorm.Statement{DB: tx} + if err := st.Parse(m); err != nil { + return err + } + if st.Schema.Table == "past_user" || st.Schema.Table == "past_file_deletion" { + continue + } + filters := []string{} + args := []any{} + for _, f := range st.Schema.Fields { + switch f.DBName { + case "user_id", "created_by", "from_user_id", "to_user_id", "requester_id": + filters = append(filters, `"`+f.DBName+`"=?`) + args = append(args, uid) + } + } + if len(filters) > 0 { + if err := tx.Exec(`DELETE FROM "`+st.Schema.Table+`" WHERE `+strings.Join(filters, " OR "), args...).Error; err != nil { + return err + } + } + } + if err := tx.Unscoped().Delete(&model.User{}, "id=?", uid).Error; err != nil { + return err + } + for _, id := range fileIds { + var file model.File + if err := tx.Unscoped().First(&file, "id=?", id).Error; err != nil { + return err + } + // Serialize with MD5 dedup uploads. Shared objects remain for other users. + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", file.Bucket+":"+file.Md5).Error; err != nil { + return err + } + referenced := false + for _, m := range model.Models() { + st := &gorm.Statement{DB: tx} + if err := st.Parse(m); err != nil { + return err + } + if st.Schema.Table == "past_file_deletion" { + continue + } + filters := []string{} + args := []any{} + for _, f := range st.Schema.Fields { + if f.DBName == "file_id" || strings.HasSuffix(f.DBName, "_file_id") { + filters = append(filters, `"`+f.DBName+`"=?`) + args = append(args, id) + } + } + if len(filters) > 0 { + var n int64 + if err := tx.Table(st.Schema.Table).Where(strings.Join(filters, " OR "), args...).Count(&n).Error; err != nil { + return err + } + if n > 0 { + referenced = true + break + } + } + } + if !referenced { + if err := tx.Create(&model.FileDeletion{FileId: id}).Error; err != nil { + return err + } + if err := tx.Delete(&file).Error; err != nil { + return err + } + } + } + return nil + }) +} diff --git a/past-go/internal/store/repository.go b/past-go/internal/store/repository.go index dcdee04..c8a84a6 100644 --- a/past-go/internal/store/repository.go +++ b/past-go/internal/store/repository.go @@ -225,6 +225,12 @@ func execute(db *gorm.DB, q string, args ...any) (writeResult, error) { return result, err } func executeSQL(db *gorm.DB, q string, args ...any) (writeResult, error) { + if actor, ok := db.Statement.Context.Value(actorKey{}).(mediaActor); ok && !actor.Admin && actor.UserId != "" && !db.DryRun { + var user model.User + if err := db.Select("id").Clauses(clause.Locking{Strength: "KEY SHARE"}).First(&user, "id=?", actor.UserId).Error; err != nil { + return writeResult{}, fmt.Errorf("账号不存在或已注销") + } + } q = strings.TrimSpace(q) pos := 0 if m := deletePattern.FindStringSubmatch(q); m != nil { diff --git a/past-go/internal/upload/upload.go b/past-go/internal/upload/upload.go index 4aff2db..fc1b838 100644 --- a/past-go/internal/upload/upload.go +++ b/past-go/internal/upload/upload.go @@ -22,7 +22,9 @@ import ( "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" + "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "past/internal/config" "past/internal/model" "past/internal/store" @@ -49,12 +51,13 @@ type ImageChecker interface { CheckImage(string, string) (bool, string) } type Uploader struct { - MaxSize int64 - Checker ImageChecker - DB *store.DB - Client *minio.Client - Bucket string - Endpoint string + HTTPClient *http.Client + MaxSize int64 + Checker ImageChecker + DB *store.DB + Client *minio.Client + Bucket string + Endpoint string } type Result struct { Path string `json:"path"` @@ -107,7 +110,7 @@ func New(cfg config.MinioConfig, db *store.DB, maxSize int64, checker ImageCheck } } } - return &Uploader{DB: db, Client: client, Bucket: cfg.Bucket, Endpoint: strings.TrimSuffix(endpoint, "/"), MaxSize: maxSize, Checker: checker}, nil + return &Uploader{HTTPClient: &http.Client{Transport: transport, Timeout: 2 * time.Minute}, DB: db, Client: client, Bucket: cfg.Bucket, Endpoint: strings.TrimSuffix(endpoint, "/"), MaxSize: maxSize, Checker: checker}, nil } // AvatarPath 对应 avatarPath($displayId) → avatars/{displayId} @@ -210,6 +213,10 @@ func (u *Uploader) save(src io.Reader, filename, userId string) Result { duplicate := false err = u.DB.ORM.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if userId != "" { + var owner model.User + if err := tx.Select("id").Clauses(clause.Locking{Strength: "KEY SHARE"}).First(&owner, "id=?", userId).Error; err != nil { + return fmt.Errorf("账号不存在或已注销") + } owners, err := store.UploadOwners(tx, userId) if err != nil { return err @@ -304,3 +311,51 @@ func (u *Uploader) PresignedURL(ctx context.Context, id string) (string, error) // Removing one business reference must never delete a shared physical object. func (u *Uploader) Remove(path string) {} + +// CleanupDeletedFiles keeps metadata and the queue until MinIO confirms removal. +func (u *Uploader) CleanupDeletedFiles() error { + var jobs []model.FileDeletion + if err := u.DB.ORM.Limit(50).Find(&jobs).Error; err != nil { + return err + } + for _, job := range jobs { + err := u.DB.ORM.Transaction(func(tx *gorm.DB) error { + var file model.File + if err := tx.Unscoped().First(&file, "id=?", job.FileId).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Unscoped().Delete(&job).Error + } else if err != nil { + return err + } + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?,0))", file.Bucket+":"+file.Md5).Error; err != nil { + return err + } + if err := tx.Unscoped().First(&file, "id=?", job.FileId).Error; err != nil { + return err + } + if !file.DeletedAt.Valid { + return tx.Unscoped().Delete(&job).Error + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := u.Client.RemoveObject(ctx, file.Bucket, file.ObjectKey, minio.RemoveObjectOptions{}); err != nil { + return err + } + if err := tx.Unscoped().Delete(&file).Error; err != nil { + return err + } + return tx.Unscoped().Delete(&job).Error + }) + if err != nil { + return err + } + } + return nil +} +func (u *Uploader) RunFileCleanupWorker() { + for { + if err := u.CleanupDeletedFiles(); err != nil { + zap.S().Errorw("注销文件清理失败,将自动重试", "err", err) + } + time.Sleep(30 * time.Second) + } +} diff --git a/past-go/internal/wechat/checkin.go b/past-go/internal/wechat/checkin.go new file mode 100644 index 0000000..c2850d2 --- /dev/null +++ b/past-go/internal/wechat/checkin.go @@ -0,0 +1,55 @@ +package wechat + +import ( + "strings" + "time" +) + +// CheckinMessage matches template 15198: 留言内容、打卡时间、温馨提示。 +// Persist the note on the activity so immediate sends and retries use the same content. +func CheckinMessage(title, note, timestamp string) map[string]string { + content := strings.TrimSpace(note) + if content == "" { + content = title + if !strings.HasPrefix(content, "打卡「") { + content = "打卡「" + content + "」" + } + } + // Database rows may use RFC3339; display all check-in times in China time. + for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} { + if t, err := time.ParseInLocation(layout, timestamp, time.FixedZone("CST", 8*3600)); err == nil { + timestamp = t.In(time.FixedZone("CST", 8*3600)).Format("2006年01月02日 15:04") + break + } + } + return normalizeSubscribeData(map[string]string{ + "thing1": content, + "time2": timestamp, + "thing3": "TA已完成打卡,点击看看吧", + }) +} + +// ReminderMessage adapts all activity notifications to the shared three-field +// template. Legacy callers keep their content and landing page. +func ReminderMessage(data map[string]string) map[string]string { + if data["time2"] != "" { + return normalizeSubscribeData(map[string]string{"thing1": data["thing1"], "time2": data["time2"], "thing3": data["thing3"]}) + } + content := data["thing2"] + if strings.TrimSpace(content) == "" { + content = data["thing1"] + } + if strings.TrimSpace(content) == "" { + content = "有新的记录" + } + stamp := data["event_time"] + if stamp == "" { + stamp = time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05") + } + out := CheckinMessage("", content, stamp) + out["thing3"] = data["thing4"] + if strings.TrimSpace(out["thing3"]) == "" { + out["thing3"] = "TA有新的动态,点击查看" + } + return normalizeSubscribeData(out) +} diff --git a/past-go/internal/wechat/checkin_test.go b/past-go/internal/wechat/checkin_test.go new file mode 100644 index 0000000..8dbe9eb --- /dev/null +++ b/past-go/internal/wechat/checkin_test.go @@ -0,0 +1,50 @@ +package wechat + +import ( + "context" + "database/sql/driver" + "encoding/json" + "io" + "net/http" + "past/internal/store" + "past/internal/testdb" + "strings" + "testing" + "time" +) + +func TestCheckinTemplatePayload(t *testing.T) { + const template = "K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU" + db, _ := testdb.Open(t, []testdb.Step{ + {Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"partner"}}}, + {Match: "SELECT id FROM subscribe_templates", Args: []any{int64(2), "notify", template}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(1)}}}, + {Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}}, + {Match: "INSERT INTO subscribe_message_logs"}, + }) + c := New("app", "secret", "old-template", testRedis(t), "checkin:", store.WrapSQL(db)) + c.NotifyTemplateID = template + c.Redis.Set(context.Background(), c.tokenKey(), "cached-token", time.Hour) + c.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) { + var p struct { + Template string `json:"template_id"` + Page string `json:"page"` + Data map[string]struct { + Value string `json:"value"` + } `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + t.Fatal(err) + } + if p.Template != template || p.Page != "pages/punch/punch" || len(p.Data) != 3 || p.Data["thing1"].Value != "今天也坚持啦" || p.Data["time2"].Value != "2026年11月03日 20:58" || p.Data["thing3"].Value == "" { + t.Fatalf("wrong payload: %+v", p) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"errcode":0}`))}, nil + }) + if !c.SendSubscribeMessage("2", "checkin", CheckinMessage("运动", "今天也坚持啦", "2026-11-03T12:58:00Z"), "pages/punch/punch", "", "") { + t.Fatal("send failed") + } + data := CheckinMessage(strings.Repeat("爱", 30), "", "2026-11-03 20:58:00") + if len([]rune(data["thing1"])) != 20 || data["time2"] != "2026年11月03日 20:58" { + t.Fatalf("invalid fallback: %v", data) + } +} diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go index dfa5c06..260d08a 100644 --- a/past-go/internal/wechat/subscribe_test.go +++ b/past-go/internal/wechat/subscribe_test.go @@ -41,18 +41,18 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { if tt.subscribed { auth = [][]driver.Value{{int64(1)}} } - steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth}) + steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify", tt.template}, Columns: []string{"id"}, Rows: auth}) } meta := []testdb.Step{{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(5)}}}, {Match: "SELECT action_type FROM activity_log", Columns: []string{"action_type"}, Rows: [][]driver.Value{{"diary_create"}}}} steps = append(steps, meta...) steps = append(steps, testdb.Step{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error { v := testdb.InsertArgs(q, args) args = []any{v["activity_id"], v["couple_id"], v["user_id"], v["to_user_id"], v["to_openid"], v["action_type"], v["message_title"], v["message_content"], v["template_type"], v["template_id"], v["page"], v["status"], v["error_code"], v["http_status"], v["error_msg"], v["attempts"], v["duration_ms"], v["sent_at"]} - if args[4] != tt.openid || args[5] != "diary_create" || args[6] != "new diary" { + if args[4] != tt.openid || args[5] != "diary_create" || args[6] != strings.Repeat("爱", 20) { t.Errorf("wrong recipient/event: %v", args) return nil } - if args[7] != strings.Repeat("爱", 20) { + if !strings.Contains(fmt.Sprint(args[7]), strings.Repeat("爱", 20)) { t.Errorf("log must record normalized content: %v", args[7]) } status := "failed" @@ -87,10 +87,14 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { calls++ var payload map[string]any json.NewDecoder(r.Body).Decode(&payload) + fields := payload["data"].(map[string]any) + if len(fields) != 3 || fields["time2"] == nil || fields["thing3"] == nil { + t.Fatalf("legacy fields leaked into shared template: %v", fields) + } if payload["touser"] != tt.openid { t.Fatal("wrong WeChat recipient") } - if payload["data"].(map[string]any)["thing2"].(map[string]any)["value"] != strings.Repeat("爱", 20) { + if payload["data"].(map[string]any)["thing1"].(map[string]any)["value"] != strings.Repeat("爱", 20) { t.Fatal("overlong content sent to WeChat") } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(tt.response))}, nil diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index 01c3c33..21d8dd8 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -43,7 +43,7 @@ func New(appID, secret, notifyTemplateID string, cache redis.UniversalClient, pr // Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES func (c *Client) Templates() map[string]string { - return map[string]string{"notify": c.NotifyTemplateID} + return map[string]string{"notify": c.NotifyTemplateID, "checkin": c.NotifyTemplateID} } // postJSON 对应 wechatPost:非 200 返回 nil @@ -157,6 +157,9 @@ func (c *Client) Jscode2Session(code string) (openid string, sessionKey string, // SendSubscribeMessage 对应 sendSubscribeMessage()。 func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data map[string]string, page string, activityID string, userID string) bool { + if templateType == "checkin" { + templateType = "notify" + } // 同一事件的即时发送和补发串行,并在发送前重新检查成功记录。 if activityID != "" { lock := &c.sendLocks[messageLockHash(activityID+toUserID)%uint64(len(c.sendLocks))] @@ -171,7 +174,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data return true } } - data = normalizeSubscribeData(data) + data = ReminderMessage(data) started := time.Now() templateID, known := c.Templates()[templateType] status, lastError, openid := "failed", "", "" @@ -202,7 +205,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data lastError = "用户无openid" return false } - sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) + sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type IN (?, 'checkin') AND template_id = ?", toUserID, templateType, templateID) if err != nil { lastError = "读取订阅授权失败" return false @@ -277,8 +280,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data continue } // 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录: - // 这样 my_subscribe_templates 始终返回“已订阅”,前端不会因此重复弹出订阅授权框, - // 满足“用户只需订阅一次、之后不再重复勾选”。发送失败仅记录日志,由前端在操作前静默续订补足次数。 + // 登记不代表剩余次数;用户后续点击时仍需调用订阅授权补充次数。 break } return false @@ -326,8 +328,12 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID string, templa if status == "success" { sentAt = time.Now().Format("2006-01-02 15:04:05") } + content := data["thing2"] + if data["time2"] != "" { + content = data["thing1"] + " · " + data["time2"] + " · " + data["thing3"] + } _, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - activityID, coupleID, userID, toUserID, openid, action, data["thing1"], data["thing2"], templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt) + activityID, coupleID, userID, toUserID, openid, action, data["thing1"], content, templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt) if err != nil { zap.S().Errorw("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err) } diff --git a/past-go/web/admin/src/pages/Settings.tsx b/past-go/web/admin/src/pages/Settings.tsx index d2c06b9..5d06048 100644 --- a/past-go/web/admin/src/pages/Settings.tsx +++ b/past-go/web/admin/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { toast } from "sonner" import { api } from "@/lib/api" import { PageTitle } from "@/components/common" @@ -8,6 +8,14 @@ import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" export default function SettingsPage() { + const [notice, setNotice] = useState({mode:"off",enabled:false,title:"公告",content:""}) + const [noticeReady, setNoticeReady] = useState(false) + const [noticeSaving, setNoticeSaving] = useState(false) + useEffect(() => { api("adminAnnouncement", null, "GET").then(r => {setNotice(r);setNoticeReady(true)}).catch(e => toast.error(e.message)) }, []) + const saveNotice = async () => { + setNoticeSaving(true) + try { await api("adminAnnouncement",notice);toast.success("公告已保存,首页下次打开时生效") } catch(e) {toast.error((e as Error).message)} finally {setNoticeSaving(false)} + } const [username, setUsername] = useState("") const [oldPw, setOldPw] = useState("") const [newPw, setNewPw] = useState("") @@ -42,6 +50,13 @@ export default function SettingsPage() { return (
系统设置 + 首页弹窗公告 +
+
setNotice({...notice,title:e.target.value})}/>
+