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})}/>
+
+ 登录通知一次:每次冷启动登录后,同一版公告只展示一次;总是通知:每次进入首页都展示。选择不通知或内容为空时不弹窗。
+
+
修改用户名
diff --git a/weapp/app.js b/weapp/app.js
index 3a9d7fa..7272b5a 100644
--- a/weapp/app.js
+++ b/weapp/app.js
@@ -14,8 +14,10 @@ App({
onLaunch: function() {
this.removeLegacyDataCaches();
this._authListeners = [];
+ require('./utils/subscribe').resetLoginRenewal();
// 冷启动始终换取新 token,所有业务请求共享这一次登录。
- this.silentLogin().catch(function() {});
+ if (wx.getStorageSync('accountDeleted')) this.setAuthState('error','账号已注销。再次进入将创建新账号,原数据不会恢复。');
+ else this.silentLogin().catch(function() {});
this.checkNetwork();
wx.onNetworkStatusChange((res) => {
this.globalData.isConnected = res.isConnected;
@@ -23,6 +25,16 @@ App({
});
},
+ finishAccountDeletion: function() {
+ require('./utils/data-cache').clear();
+ require('./utils/announcement-session').clear();
+ require('./utils/subscribe').resetLoginRenewal();
+ this._authGeneration=(this._authGeneration||0)+1;this._authPromise=null;
+ wx.clearStorageSync();
+ wx.setStorageSync('accountDeleted',true);
+ this.globalData.token=null;this.globalData.userId=null;
+ this.setAuthState('error','账号已注销。再次进入将创建新账号,原数据不会恢复。');
+ },
onRelationChanged: function() {
if (this._relationRedirect) return;
wx.removeStorageSync('petHasPet');
@@ -35,13 +47,19 @@ App({
this._relationSync = true;
const before = String(wx.getStorageSync('coupleId') || '');
try {
- await api.checkAuth();
+ const auth = await api.checkAuth({cache:false, silent:true});
+ const changed = require('./utils/data-cache').applyRemoteRevision(auth.data_revision);
if (before !== String(wx.getStorageSync('coupleId') || '')) {
this.onRelationChanged();
+ } else if (changed && typeof getCurrentPages === 'function') {
+ const pages = getCurrentPages();
+ const page = pages[pages.length - 1];
+ if (page && page.onDataChanged) page.onDataChanged();
}
} catch(e) {} finally { this._relationSync = false; }
};
sync();
+ if (this.globalData.authState === 'ready') require('./utils/subscribe').prepareLoginRenewal();
if (!this._relationTimer) this._relationTimer = setInterval(sync, 30000);
},
onHide: function() { if(this._relationTimer) clearInterval(this._relationTimer); this._relationTimer=null; },
@@ -65,6 +83,7 @@ App({
},
// 多个旧 token 请求同时返回 401 时,只刷新一次;较晚返回的旧请求复用新 token。
refreshAuth: function(failedToken) {
+ if(wx.getStorageSync('accountDeleted')) return Promise.reject({error:'账号已注销',accountDeleted:true});
if (this._authPromise) return this._authPromise;
if (this.globalData.authState === 'ready' && wx.getStorageSync('token') !== failedToken) return Promise.resolve();
return this.silentLogin();
@@ -109,20 +128,24 @@ App({
silentLogin: function() {
if (this._authPromise) return this._authPromise;
+ const generation=this._authGeneration||0;
this.setAuthState('loading');
this._authPromise = this.performLogin().then((res) => {
this.setAuthState('ready');
+ require('./utils/subscribe').prepareLoginRenewal();
return res;
}).catch((error) => {
+ if(generation!==(this._authGeneration||0))throw error;
wx.removeStorageSync('token');
this.globalData.token = null;
var failure = { error: (error && error.error) || '登录失败,请检查网络后重试', authFailed: true };
this.setAuthState('error', failure.error);
throw failure;
- }).finally(() => { this._authPromise = null; });
+ }).finally(() => { if(generation===(this._authGeneration||0))this._authPromise = null; });
return this._authPromise;
},
performLogin: async function() {
+ const generation=this._authGeneration||0;
var loginRes = await new Promise(function(resolve, reject) {
var done = false;
var timer = setTimeout(function() {
@@ -136,7 +159,9 @@ App({
if (!loginRes.code) throw { error: '未能获取微信登录凭证,请重试' };
// 定位授权不阻塞登录,城市信息由页面后续的定位接口更新。
var res = await api.wechatLogin(loginRes.code, '');
+ if (generation!==(this._authGeneration||0)) throw {error:'账号状态已变更,请重新进入'};
if (!res || !res.token || !res.user_id) throw { error: '登录响应不完整,请重试' };
+ wx.removeStorageSync('accountDeleted');
wx.setStorageSync('token', res.token);
wx.setStorageSync('userId', res.user_id);
wx.setStorageSync('coupleId', res.couple_id || '');
@@ -144,6 +169,7 @@ App({
wx.setStorageSync('nickname', res.nickname || '');
wx.setStorageSync('region', res.region || '');
wx.setStorageSync('hasLoggedIn', true);
+ require('./utils/data-cache').applyRemoteRevision(res.data_revision);
this.globalData.token = res.token;
this.globalData.userId = res.user_id;
return res;
diff --git a/weapp/custom-tab-bar/index.js b/weapp/custom-tab-bar/index.js
index 06df7f8..2d0676b 100644
--- a/weapp/custom-tab-bar/index.js
+++ b/weapp/custom-tab-bar/index.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../utils/subscribe');
const { tabForPage, paths } = require('../utils/navigation');
Component({
data: { active: '', hidden: false },
@@ -11,6 +12,7 @@ Component({
this.setData({ active: tabForPage(current && current.route), hidden: !!(current && current.data && (current.data.publishOpen || current.data.showEditProfileModal || current.data.showEditPartnerModal)) });
},
navigate: function(e) {
+ tryLoginRenewal();
var tab = e.currentTarget.dataset.tab;
if (!paths[tab]) return;
var pages = getCurrentPages();
diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js
index c964ba7..cdac8a8 100644
--- a/weapp/pages/album/album.js
+++ b/weapp/pages/album/album.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { uploadReference } = require('../../utils/file-media');
const { itemsOf, startList, current, mergeUnique, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { currentUserLocation } = require('../../utils/user-location');
@@ -6,9 +7,9 @@ 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');
const { guardStorageSpace } = require('../../utils/storage-guard');
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
batches: [],
@@ -317,9 +318,8 @@ Page(withDataCache(withListPagination({
var successCount = 0;
var failCount = 0;
var failReasons = [];
+ var quotaReached = false;
- // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
- await requestSubscribeAfterAction('photo');
try {
var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId;
@@ -372,6 +372,12 @@ Page(withDataCache(withListPagination({
failReasons.push('第' + (i+1) + '张: 上传返回无路径 - ' + JSON.stringify(uploadRes));
}
} catch (e) {
+ if (e && Number(e.code) === 10001) {
+ quotaReached = true;
+ failCount += paths.length - i;
+ wx.hideLoading();
+ break;
+ }
failCount++;
failReasons.push('第' + (i+1) + '张: ' + (e.error || e.errMsg || e.message || JSON.stringify(e)));
// 内容安全违规:明确提示用户
@@ -390,7 +396,7 @@ Page(withDataCache(withListPagination({
var msg = '上传完成 ' + successCount + '/' + paths.length;
if (failCount > 0) msg += '(' + failCount + '张失败)';
- wx.showToast({ title: msg, icon: successCount > 0 ? 'success' : 'none', duration: 2000 });
+ if (!quotaReached) wx.showToast({ title: msg, icon: successCount > 0 ? 'success' : 'none', duration: 2000 });
// 如果有失败,输出详细原因到控制台
if (failCount > 0 && failReasons.length > 0) {
diff --git a/weapp/pages/album/album.wxml b/weapp/pages/album/album.wxml
index ac3180a..f75d232 100644
--- a/weapp/pages/album/album.wxml
+++ b/weapp/pages/album/album.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/albums/albums.js b/weapp/pages/albums/albums.js
index eb8f097..d04ee70 100644
--- a/weapp/pages/albums/albums.js
+++ b/weapp/pages/albums/albums.js
@@ -1,9 +1,11 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/albums/albums.js
const { api, getImageUrl } = require('../../utils/request');
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
diff --git a/weapp/pages/albums/albums.wxml b/weapp/pages/albums/albums.wxml
index ab20807..1cf2d2c 100644
--- a/weapp/pages/albums/albums.wxml
+++ b/weapp/pages/albums/albums.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js
index 88531c4..047febf 100644
--- a/weapp/pages/checkin-detail/checkin-detail.js
+++ b/weapp/pages/checkin-detail/checkin-detail.js
@@ -1,13 +1,14 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { uploadReference } = require('../../utils/file-media');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/checkin-detail/checkin-detail.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
-const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
const COOLDOWN = 5000;
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, item: null, logs: [], logsPage: 1, logsTotal: 0, logsHasMore: false, logsLoading: false, logsError: false, freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' },
@@ -180,8 +181,6 @@ Page(withDataCache({
this._submittingCheck = true;
try {
- // 为本人接收TA的提醒补充授权。
- await requestSubscribeAfterAction('checkin');
var photoPath = '';
if (this.data.checkPhoto) {
wx.showLoading({ title: '上传中...' });
diff --git a/weapp/pages/checkin-detail/checkin-detail.wxml b/weapp/pages/checkin-detail/checkin-detail.wxml
index 5aec539..ff3a07a 100644
--- a/weapp/pages/checkin-detail/checkin-detail.wxml
+++ b/weapp/pages/checkin-detail/checkin-detail.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/diary/diary.js b/weapp/pages/diary/diary.js
index 2507c86..c3fb094 100644
--- a/weapp/pages/diary/diary.js
+++ b/weapp/pages/diary/diary.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
@@ -6,6 +7,7 @@ const { api, getImageUrl } = require('../../utils/request');
const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
diff --git a/weapp/pages/diary/diary.wxml b/weapp/pages/diary/diary.wxml
index 10e1514..198675f 100644
--- a/weapp/pages/diary/diary.wxml
+++ b/weapp/pages/diary/diary.wxml
@@ -1,6 +1,6 @@
-
+
LIFE IN WORDS生活里的每一页
diff --git a/weapp/pages/diary_detail/diary_detail.js b/weapp/pages/diary_detail/diary_detail.js
index 4373080..8a3fa04 100644
--- a/weapp/pages/diary_detail/diary_detail.js
+++ b/weapp/pages/diary_detail/diary_detail.js
@@ -1,8 +1,10 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, totalOf, mergeUnique } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
const { api, getImageUrl } = require('../../utils/request');
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
noop: function() {},
onResize: function() { this.setData(getLayout()); },
data: { loadError: false,
diff --git a/weapp/pages/diary_detail/diary_detail.wxml b/weapp/pages/diary_detail/diary_detail.wxml
index bc1880d..cf68119 100644
--- a/weapp/pages/diary_detail/diary_detail.wxml
+++ b/weapp/pages/diary_detail/diary_detail.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/diary_edit/diary_edit.js b/weapp/pages/diary_edit/diary_edit.js
index e8e1cd3..12ab46d 100644
--- a/weapp/pages/diary_edit/diary_edit.js
+++ b/weapp/pages/diary_edit/diary_edit.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { uploadReference } = require('../../utils/file-media');
const { getLayout } = require('../../utils/layout');
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
@@ -7,6 +8,7 @@ var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
editorContent: '', loading: true, loadError: false,
diff --git a/weapp/pages/diary_edit/diary_edit.wxml b/weapp/pages/diary_edit/diary_edit.wxml
index 6fb8446..ac925c0 100644
--- a/weapp/pages/diary_edit/diary_edit.wxml
+++ b/weapp/pages/diary_edit/diary_edit.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/discover/discover.js b/weapp/pages/discover/discover.js
index e604657..9955c2e 100644
--- a/weapp/pages/discover/discover.js
+++ b/weapp/pages/discover/discover.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, totalOf } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
@@ -7,6 +8,7 @@ const { diaryEntry, albumEntry, memoryEntry } = require('../../utils/journal');
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
stats: null, loading: true, loadError: false, refreshing: false, publishOpen: false,
diff --git a/weapp/pages/discover/discover.wxml b/weapp/pages/discover/discover.wxml
index d217595..8605490 100644
--- a/weapp/pages/discover/discover.wxml
+++ b/weapp/pages/discover/discover.wxml
@@ -1,6 +1,6 @@
-
+
一起的日常
歌曲打卡宠物姨姨
diff --git a/weapp/pages/history/history.js b/weapp/pages/history/history.js
index a1d1acf..a61f170 100644
--- a/weapp/pages/history/history.js
+++ b/weapp/pages/history/history.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { getLayout } = require('../../utils/layout');
// pages/history/history.js
const { api } = require('../../utils/request');
@@ -23,6 +24,7 @@ var ACTION_CONFIG = {
};
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loadError: false, list: [], page: 1, totalPages: 0, pages: [], loading: true },
diff --git a/weapp/pages/history/history.wxml b/weapp/pages/history/history.wxml
index 0e39efa..5205eee 100644
--- a/weapp/pages/history/history.wxml
+++ b/weapp/pages/history/history.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/index/index.js b/weapp/pages/index/index.js
index de8041b..b55d291 100644
--- a/weapp/pages/index/index.js
+++ b/weapp/pages/index/index.js
@@ -1,3 +1,5 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
+const announcementSession = require('../../utils/announcement-session');
const { itemsOf, totalOf } = require('../../utils/list-pagination');
let lastLocationReport = 0;
let locationReportUser = null;
@@ -9,8 +11,10 @@ const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
+ showAnnouncement:false, announcement:null,
userId: null,
coupleId: null,
@@ -53,6 +57,8 @@ Page(withDataCache({
},
onShow: function() {
+ this._announcementActive=true;
+ this.loadAnnouncement();
resetScrollOnTabShow(this, 'index');
const tabBar = this.getTabBar && this.getTabBar();
if (tabBar) tabBar.syncActive();
@@ -72,6 +78,26 @@ Page(withDataCache({
this.fetchIpLocation();
},
+ onHide: function() { this._announcementActive=false; },
+ onUnload: function() { this._announcementActive=false; },
+ loadAnnouncement: function() {
+ if(this._announcementLoading)return this._announcementLoading;
+ this._announcementLoading=(async()=>{
+ try {
+ const notice=await api.announcement();
+ if(this._announcementActive===false)return;
+ const userId=wx.getStorageSync('userId');
+ const show=announcementSession.shouldShow(notice,userId);
+ this.setData({announcement:notice, showAnnouncement:show});
+ if(show)announcementSession.markShown(notice,userId);
+ } catch(e) {} // 公告读取失败不阻塞首页。
+ })().finally(()=>{this._announcementLoading=null;});
+ return this._announcementLoading;
+ },
+ closeAnnouncement: function() {
+ this.setData({showAnnouncement:false});
+ },
+ preventNoticeMove: function() {},
// 保存滚动位置
onScroll: function(e) {
this._scrollTop = e.detail.scrollTop;
diff --git a/weapp/pages/index/index.wxml b/weapp/pages/index/index.wxml
index 75c45d6..2ba0b2c 100644
--- a/weapp/pages/index/index.wxml
+++ b/weapp/pages/index/index.wxml
@@ -1,6 +1,6 @@
-
+
@@ -39,3 +39,5 @@
相册里的片段全部相册 →{{item.title}}
+
+我们 past · 公告{{announcement.title}}{{announcement.content}}
diff --git a/weapp/pages/index/index.wxss b/weapp/pages/index/index.wxss
index 2afc3d0..843fc6e 100644
--- a/weapp/pages/index/index.wxss
+++ b/weapp/pages/index/index.wxss
@@ -55,3 +55,5 @@
.home-has-background .j-section-head { text-shadow:0 1rpx 5rpx rgba(255,253,248,.85); }
.home-has-background .home-couple-panel { padding:24rpx; border:1rpx solid rgba(233,221,208,.65); border-radius:24rpx; background:rgba(255,253,248,.82); }
+
+.notice-overlay {position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;background:rgba(49,36,42,.46);padding:48rpx;box-sizing:border-box;}.notice-dialog{width:100%;max-width:640rpx;background:#FFFCF8;border-radius:36rpx;padding:44rpx;box-sizing:border-box;}.notice-label{display:block;color:#9B7B83;font-size:22rpx;}.notice-title{display:block;font-size:36rpx;color:#64424D;margin:16rpx 0 28rpx;}.notice-content{max-height:50vh;white-space:pre-wrap;font-size:28rpx;line-height:1.8;color:#5F5557;}.notice-dismiss{margin-top:32rpx;background:#667E75;color:#fff;border-radius:20rpx;font-size:28rpx;}
diff --git a/weapp/pages/lovewords/lovewords.js b/weapp/pages/lovewords/lovewords.js
index fa696cf..b14d2ce 100644
--- a/weapp/pages/lovewords/lovewords.js
+++ b/weapp/pages/lovewords/lovewords.js
@@ -1,9 +1,11 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/lovewords/lovewords.js - 一起听过的歌
const { api } = require('../../utils/request');
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: [],
diff --git a/weapp/pages/lovewords/lovewords.wxml b/weapp/pages/lovewords/lovewords.wxml
index 59a3835..8c2cfee 100644
--- a/weapp/pages/lovewords/lovewords.wxml
+++ b/weapp/pages/lovewords/lovewords.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/period/period.js b/weapp/pages/period/period.js
index 47094b3..3a95bdf 100644
--- a/weapp/pages/period/period.js
+++ b/weapp/pages/period/period.js
@@ -1,9 +1,11 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { getLayout } = require('../../utils/layout');
// pages/period/period.js - 姨姨(美柚风格日历模式)
const { api } = require('../../utils/request');
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
// 日历
diff --git a/weapp/pages/period/period.wxml b/weapp/pages/period/period.wxml
index 9d153c6..47a287f 100644
--- a/weapp/pages/period/period.wxml
+++ b/weapp/pages/period/period.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/pet/achievements.js b/weapp/pages/pet/achievements.js
index a40c84b..acca8da 100644
--- a/weapp/pages/pet/achievements.js
+++ b/weapp/pages/pet/achievements.js
@@ -1,9 +1,11 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/pet/achievements.js
const { api } = require('../../utils/request');
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
achievements: [],
diff --git a/weapp/pages/pet/achievements.wxml b/weapp/pages/pet/achievements.wxml
index 6da0ff6..64a32a6 100644
--- a/weapp/pages/pet/achievements.wxml
+++ b/weapp/pages/pet/achievements.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/pet/pet.js b/weapp/pages/pet/pet.js
index f29168a..e1b5360 100644
--- a/weapp/pages/pet/pet.js
+++ b/weapp/pages/pet/pet.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/pet/pet.js
@@ -20,6 +21,7 @@ const BREED_EMOJIS = {
const STAGE_NAMES = ['', '幼犬期', '少年期', '青年期', '成年期', '终极期'];
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, needsBinding: false,
hasPet: false,
diff --git a/weapp/pages/pet/pet.wxml b/weapp/pages/pet/pet.wxml
index e4d1baf..28b4a1f 100644
--- a/weapp/pages/pet/pet.wxml
+++ b/weapp/pages/pet/pet.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js
index 38e4035..31db4b4 100644
--- a/weapp/pages/profile/profile.js
+++ b/weapp/pages/profile/profile.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { uploadReference } = require('../../utils/file-media');
const { itemsOf, totalOf } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
@@ -5,7 +6,6 @@ const { getLayout } = require('../../utils/layout');
// pages/profile/profile.js - 与原网站一致的个人中心
const { api, getImageUrl } = require('../../utils/request');
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
-const { requestSubscribe } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
function formatStorageBytes(bytes) {
@@ -16,6 +16,7 @@ function formatStorageBytes(bytes) {
}
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
scrollTop: 0,
@@ -904,8 +905,6 @@ Page(withDataCache({
const { inviteMessage } = this.data;
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
if (!/^[0-9]{6}$/.test(inviteTargetId)) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; }
- // 邀请成功且对方接受时,需要通知邀请人,提前申请一次性订阅授权
- requestSubscribe('notify').catch(function() {});
try {
wx.showLoading({ title: '发送中...' });
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
diff --git a/weapp/pages/profile/profile.wxml b/weapp/pages/profile/profile.wxml
index df2166c..bb4dbfc 100644
--- a/weapp/pages/profile/profile.wxml
+++ b/weapp/pages/profile/profile.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/profile/profile.wxss b/weapp/pages/profile/profile.wxss
index 9042eb0..b16dad0 100644
--- a/weapp/pages/profile/profile.wxss
+++ b/weapp/pages/profile/profile.wxss
@@ -325,7 +325,7 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
.profile-stats-retry { text-align:center; color:#B74768; font-size:22rpx; padding:24rpx 0 0; }
/* 存储空间入口卡片。 */
-.j-storage-card { margin-top:32rpx; padding:26rpx 30rpx; background:#FFFDF8; border:1rpx solid #F1DCE4; border-radius:32rpx; }
+.j-storage-card { margin-top:32rpx; padding:26rpx 30rpx; background:linear-gradient(135deg,#FFF7F9,#FBE8EF); border:1rpx solid #F1DCE4; border-radius:32rpx; }
.j-storage-top { display:flex; justify-content:space-between; align-items:baseline; }
.j-storage-label { font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-size:28rpx; color:#64424D; }
.j-storage-val { font-size:26rpx; font-weight:600; color:#B74768; }
@@ -335,12 +335,12 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
.j-storage-hint { font-size:22rpx; color:#A39386; }
.j-storage-cta { font-size:24rpx; color:#B74768; font-weight:500; }
-/* 会员(无限空间)卡片:玫红渐变,突出「无限空间」与到期时间。 */
-.j-storage-vip { background:linear-gradient(135deg,#FF9EB5,#B74768); border:1rpx solid rgba(255,255,255,.4); border-radius:32rpx; }
-.j-storage-vip .j-storage-label { color:#FFF; }
-.j-storage-vip .j-storage-val.j-vip { color:#FFF; font-size:28rpx; }
-.j-storage-vip .j-storage-hint { color:rgba(255,255,255,.9); }
-.j-storage-vip .j-storage-cta { color:#FFF; }
+/* 延续玫粉色系,降低饱和度,深色文字保持清晰。 */
+.j-storage-vip { background:linear-gradient(135deg,#F8D6E1,#E9A9BF); border:1rpx solid #E8B5C7; border-radius:32rpx; }
+.j-storage-vip .j-storage-label { color:#6E3549; }
+.j-storage-vip .j-storage-val.j-vip { color:#6E3549; font-size:28rpx; }
+.j-storage-vip .j-storage-hint { color:#704052; }
+.j-storage-vip .j-storage-cta { color:#6E3549; }
/* 本人与对方资料卡共用完整样式,整卡点击编辑。 */
.j-body > .j-profile-summary,
diff --git a/weapp/pages/publish/publish.js b/weapp/pages/publish/publish.js
index 8b30f14..a549b46 100644
--- a/weapp/pages/publish/publish.js
+++ b/weapp/pages/publish/publish.js
@@ -1,9 +1,9 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { uploadReference } = require('../../utils/file-media');
const { getLayout } = require('../../utils/layout');
// pages/publish/publish.js - 统一发布页(增强版编辑器)
const { api, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
-const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
@@ -18,6 +18,7 @@ var TYPE_CONFIG = {
};
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
type: '',
@@ -360,17 +361,6 @@ Page({
if (!form.title) { throw new Error('请填写打卡标题'); }
}
- // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
- // 为本人接收TA的提醒补充授权,拒绝或保存失败也不影响发布
- var sceneMap = {
- diary: 'diary', album: 'photo', checkin: 'checkin',
- timeline: 'timeline', song: 'song'
- };
- var scene = sceneMap[type];
- if (scene) {
- await requestSubscribeAfterAction(scene);
- }
-
// 再执行异步操作(上传/API调用)
if (type === 'timeline') {
var editId = this.data.editId;
diff --git a/weapp/pages/publish/publish.wxml b/weapp/pages/publish/publish.wxml
index 43e73ab..aaff646 100644
--- a/weapp/pages/publish/publish.wxml
+++ b/weapp/pages/publish/publish.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/punch/punch.js b/weapp/pages/punch/punch.js
index 13751c2..8f99f9d 100644
--- a/weapp/pages/punch/punch.js
+++ b/weapp/pages/punch/punch.js
@@ -1,12 +1,14 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/punch/punch.js - 情侣打卡
const { api, getImageUrl } = require('../../utils/request');
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loadError: false,
- checkins: [],
+ checkins: [], smokingVisible: false, smokingKnown: false, smokingSaving: false, smokingError: false,
coupleInfo: null,
myAvatar: '', partnerAvatar: '',
myName: '我', partnerName: 'TA',
@@ -20,8 +22,20 @@ Page(withDataCache(withListPagination({
this.loadCoupleInfo();
this.loadData();
},
- onShow: function() { this.loadCoupleInfo(); this.loadData(); },
+ onShow: function() { this.loadSmokingPreference(); this.loadCoupleInfo(); this.loadData(); },
+ loadSmokingPreference: async function() {
+ try { const auth=await api.checkAuth(); this.setData({smokingVisible:Number(auth.user.smoking_card_visible)!==0, smokingKnown:true, smokingError:false}); }
+ catch(e) { this.setData({smokingError:true}); }
+ },
+ toggleSmoking: async function(e) {
+ if (this.data.smokingSaving || !this.data.smokingKnown) return;
+ const previous=this.data.smokingVisible;
+ this.setData({smokingSaving:true});
+ try { const result=await api.smokingCardVisible({visible:e.detail.value?1:0}); this.setData({smokingVisible:Number(result.card_visible)===1}); }
+ catch(error) { this.setData({smokingVisible:previous}); wx.showToast({title:error.error||'保存失败,请重试',icon:'none'}); }
+ finally { this.setData({smokingSaving:false}); }
+ },
onSmokingUpdated: function() { this.loadData(); },
onPullDownRefresh: async function() {
@@ -120,4 +134,4 @@ Page(withDataCache(withListPagination({
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
-}, 'loadData'), {"loadData": ["checkinList", "checkinStats", "coupleInfo"]}));
+}, 'loadData'), {"loadData": ["checkinList", "checkinStats", "coupleInfo"], "loadSmokingPreference":["checkAuth"], "loadCoupleInfo":["coupleInfo"]}));
diff --git a/weapp/pages/punch/punch.wxml b/weapp/pages/punch/punch.wxml
index 1127406..5c875ee 100644
--- a/weapp/pages/punch/punch.wxml
+++ b/weapp/pages/punch/punch.wxml
@@ -1,4 +1,4 @@
-
+
@@ -25,7 +25,8 @@
-
+ 抽烟记录显示设置保存到账号,重新安装后仍生效重试加载中
+
diff --git a/weapp/pages/punch/punch.wxss b/weapp/pages/punch/punch.wxss
index 033ff4c..aeb6378 100644
--- a/weapp/pages/punch/punch.wxss
+++ b/weapp/pages/punch/punch.wxss
@@ -13,3 +13,5 @@
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+
+.smoking-preference { display:flex; align-items:center; justify-content:space-between; gap:20rpx; margin:26rpx 0; padding:24rpx; background:#F2F7F4; border-radius:24rpx; }
diff --git a/weapp/pages/settings/settings.js b/weapp/pages/settings/settings.js
index 2f58b47..45f12d0 100644
--- a/weapp/pages/settings/settings.js
+++ b/weapp/pages/settings/settings.js
@@ -1,7 +1,7 @@
const { getLayout } = require('../../utils/layout');
// pages/settings/settings.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
-const { requestSubscribe, getSubscribeStatus } = require('../../utils/subscribe');
+const { requestSubscribe, getSubscribeStatus, isTemplateConfigured, subscribeErrorMessage } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
Page({
onResize: function() { this.setData(getLayout()); },
@@ -23,6 +23,7 @@ Page({
// 消息通知设置
showSubscribeModal: false,
subscribeStatus: {},
+ notifyAvailable: typeof isTemplateConfigured === 'function' && isTemplateConfigured('notify'),
subscribeItems: [],
subscribeLoading: false,
@@ -63,6 +64,25 @@ Page({
wx.switchTab({ url: '/pages/profile/profile' });
},
+ deleteAccount: async function() {
+ if(this._deletingAccount)return;
+ this._deletingAccount=true;
+ try {
+ const preview=await api.accountDeletionPreview();
+ const counts=preview.summary.counts;
+ const text='已上传文件 '+counts.files+' 个,相册 '+counts.albums+' 个,照片 '+counts.photos+' 张,日记 '+counts.diaries+' 篇,记忆日 '+counts.timeline+' 条,打卡 '+counts.checkins+' 条,抽烟记录 '+counts.smoking+' 条,生理期日记录 '+counts.period+' 条。';
+ const labels={past_song:'歌曲',past_diary_comment:'评论',past_period_record:'生理期周期',past_period_sex_record:'亲密记录',past_storage_order:'订单',past_subscribe_template:'消息订阅'};
+ const more=Object.keys(labels).map(key=>labels[key]+' '+((preview.summary.record_counts||{})[key]||0)+' 条').join(',');
+ const confirm=options=>new Promise(resolve=>wx.showModal({...options,success:r=>resolve(r.confirm),fail:()=>resolve(false)}));
+ if(!await confirm({title:'注销账号前,请确认',content:text+' 其他数据:'+more+'。注销后,账号、所有内容及文件关联会被删除,无法恢复;剩余空间权益将失效。',confirmText:'继续注销',confirmColor:'#B74768'}))return;
+ if(!await confirm({title:'最后一次确认',content:'确定永久注销并删除全部数据?独占文件将从存储中清理;与其他用户重复的物理文件只移除你的关联,不会删除他人的内容。',confirmText:'确认注销',confirmColor:'#B74768'}))return;
+ wx.showLoading({title:'正在注销',mask:true});
+ await api.accountDelete({confirmed:true,confirmation_text:'注销并删除全部数据',confirmation_token:preview.confirmation_token});
+ getApp().finishAccountDeletion();
+ wx.reLaunch({url:'/pages/index/index'});
+ } catch(error) {wx.showToast({title:error.error||'注销失败,请稍后重试',icon:'none',duration:3000});}
+ finally {wx.hideLoading();this._deletingAccount=false;}
+ },
// 加载解绑状态
loadUnbindStatus: function() {
var self = this;
@@ -82,7 +102,7 @@ Page({
noop: function() {},
- // ===== 消息通知管理(统一使用一个模板) =====
+ // ===== 消息通知管理(按场景独立授权) =====
showSubscribeModal: async function() {
this.setData({ showSubscribeModal: true, subscribeLoading: true });
try {
@@ -110,6 +130,18 @@ Page({
this.setData({ showAboutModal: false });
},
+ openPrivacyContract: function() {
+ if (typeof wx.openPrivacyContract !== 'function') {
+ wx.showToast({ title: '请升级微信后查看隐私协议', icon: 'none' });
+ return;
+ }
+ wx.openPrivacyContract({
+ fail: function() {
+ wx.showToast({ title: '隐私协议暂时无法打开,请稍后重试', icon: 'none' });
+ }
+ });
+ },
+
// ===== 联系我们 =====
showContactModal: function() {
this.setData({ showContactModal: true });
@@ -120,7 +152,12 @@ Page({
toggleSubscribe: async function(e) {
const type = 'notify';
- const current = this.data.subscribeChecked;
+ if (typeof isTemplateConfigured === 'function' && !isTemplateConfigured(type)) {
+ wx.showToast({title:'该提醒暂未配置模板',icon:'none'});
+ return;
+ }
+ const field = 'subscribeChecked';
+ const current = this.data[field];
// 防止重复点击
if (this._subscribing) return;
@@ -130,8 +167,8 @@ Page({
// 取消订阅
try {
await api.unsubscribeTemplate({ template_type: type });
- this.setData({ subscribeChecked: false });
- wx.setStorageSync('subscribed_notify', false);
+ this.setData({ [field]: false });
+ wx.setStorageSync('subscribed_' + type, false);
wx.showToast({ title: '已关闭通知', icon: 'success' });
} catch (e) {
wx.showToast({ title: '操作失败', icon: 'none' });
@@ -141,33 +178,35 @@ Page({
try {
const results = await requestSubscribe(type);
if (results[type]) {
- this.setData({ subscribeChecked: true });
- wx.setStorageSync('subscribed_notify', true);
+ this.setData({ [field]: true });
+ wx.setStorageSync('subscribed_' + type, true);
wx.showToast({ title: '已开启通知', icon: 'success' });
} else {
wx.showToast({ title: '订阅被拒绝,可在设置中重新开启', icon: 'none' });
}
} catch (e) {
- wx.showToast({ title: '订阅失败', icon: 'none' });
+ wx.showModal({title:'无法订阅提醒',content:subscribeErrorMessage(e),showCancel:false});
}
}
this._subscribing = false;
},
- renewSubscribe: async function() {
+ renewSubscribe: async function(e) {
+ const type = 'notify';
+ const field = 'subscribeChecked';
if (this._subscribing) return;
this._subscribing = true;
try {
- const results = await requestSubscribe('notify');
- if (results.notify) {
- this.setData({ subscribeChecked: true });
+ const results = await requestSubscribe(type);
+ if (results[type]) {
+ this.setData({ [field]: true });
wx.showToast({ title: '已订阅一次提醒', icon: 'success' });
} else {
wx.showToast({ title: '本次未授权提醒', icon: 'none' });
}
} catch (e) {
- wx.showToast({ title: '订阅失败,请重试', icon: 'none' });
+ wx.showModal({title:'无法订阅提醒',content:subscribeErrorMessage(e),showCancel:false});
} finally {
this._subscribing = false;
}
diff --git a/weapp/pages/settings/settings.wxml b/weapp/pages/settings/settings.wxml
index d881f65..f854f23 100644
--- a/weapp/pages/settings/settings.wxml
+++ b/weapp/pages/settings/settings.wxml
@@ -25,6 +25,7 @@
关系与账号
+ 注销账号未绑定时可注销,确认后删除全部个人数据›
@@ -73,6 +74,14 @@
›
+
+
+ 隐私协议
+ 查看本小程序的隐私保护指引
+
+ ›
+
+
@@ -133,11 +142,11 @@
💕
- 操作通知
- TA有新的操作时通知你
+ 操作与打卡通知
+ {{notifyAvailable ? 'TA有新动态或完成打卡时通知你' : '暂未配置当前小程序的通知模板'}}
-
+
@@ -148,11 +157,11 @@
💡
- 开启并授权后,可接收一次TA的操作提醒
+ 开启并授权后,可接收一次TA的提醒
-
+
diff --git a/weapp/pages/smoking-detail/smoking-detail.js b/weapp/pages/smoking-detail/smoking-detail.js
index 6debf64..8546da6 100644
--- a/weapp/pages/smoking-detail/smoking-detail.js
+++ b/weapp/pages/smoking-detail/smoking-detail.js
@@ -1,5 +1,7 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { getLayout } = require('../../utils/layout');
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
data:{},
onLoad(){this.setData(getLayout());},
onResize(){this.setData(getLayout());},
diff --git a/weapp/pages/smoking-detail/smoking-detail.wxml b/weapp/pages/smoking-detail/smoking-detail.wxml
index 2440d13..26b8b81 100644
--- a/weapp/pages/smoking-detail/smoking-detail.wxml
+++ b/weapp/pages/smoking-detail/smoking-detail.wxml
@@ -1 +1 @@
-
+
diff --git a/weapp/pages/storage/storage.js b/weapp/pages/storage/storage.js
index dd2e821..45ec95e 100644
--- a/weapp/pages/storage/storage.js
+++ b/weapp/pages/storage/storage.js
@@ -1,3 +1,5 @@
+const { withDataCache } = require('../../utils/page-cache');
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { getLayout } = require('../../utils/layout');
const { api } = require('../../utils/request');
@@ -8,7 +10,8 @@ function formatBytes(bytes) {
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
-Page({
+Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function () { this.setData(getLayout()); },
data: {
@@ -157,4 +160,4 @@ Page({
}
},
-});
+}, {loadInfo:['storageInfo']}));
diff --git a/weapp/pages/storage/storage.wxml b/weapp/pages/storage/storage.wxml
index 8d01903..c837505 100644
--- a/weapp/pages/storage/storage.wxml
+++ b/weapp/pages/storage/storage.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/timeline-detail/timeline-detail.js b/weapp/pages/timeline-detail/timeline-detail.js
index ac73651..4b9d232 100644
--- a/weapp/pages/timeline-detail/timeline-detail.js
+++ b/weapp/pages/timeline-detail/timeline-detail.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/timeline-detail/timeline-detail.js
@@ -59,6 +60,7 @@ function calcNearestRepeatDate(eventDate, today, interval, unit) {
}
Page(withDataCache({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
item: null,
diff --git a/weapp/pages/timeline-detail/timeline-detail.wxml b/weapp/pages/timeline-detail/timeline-detail.wxml
index 4d5603b..2e58d9f 100644
--- a/weapp/pages/timeline-detail/timeline-detail.wxml
+++ b/weapp/pages/timeline-detail/timeline-detail.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/timeline/timeline.js b/weapp/pages/timeline/timeline.js
index f567c53..bda4ec4 100644
--- a/weapp/pages/timeline/timeline.js
+++ b/weapp/pages/timeline/timeline.js
@@ -1,3 +1,4 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
@@ -58,6 +59,7 @@ function calcNearestRepeatDate(eventDate, today, interval, unit) {
}
Page(withDataCache(withListPagination({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: []
diff --git a/weapp/pages/timeline/timeline.wxml b/weapp/pages/timeline/timeline.wxml
index 4d51e5e..72058e4 100644
--- a/weapp/pages/timeline/timeline.wxml
+++ b/weapp/pages/timeline/timeline.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/pages/unbind/unbind.js b/weapp/pages/unbind/unbind.js
index 94e4339..bb61ad5 100644
--- a/weapp/pages/unbind/unbind.js
+++ b/weapp/pages/unbind/unbind.js
@@ -1,8 +1,10 @@
+const { tryLoginRenewal } = require('../../utils/subscribe');
const { getLayout } = require('../../utils/layout');
// pages/unbind/unbind.js - 情侣解绑确认页
const { api } = require('../../utils/request');
Page({
+ onLoginSubscribeTap: function() { return tryLoginRenewal(); },
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
diff --git a/weapp/pages/unbind/unbind.wxml b/weapp/pages/unbind/unbind.wxml
index 869be2e..8f332b7 100644
--- a/weapp/pages/unbind/unbind.wxml
+++ b/weapp/pages/unbind/unbind.wxml
@@ -1,4 +1,4 @@
-
+
diff --git a/weapp/tests/account-preferences.test.cjs b/weapp/tests/account-preferences.test.cjs
new file mode 100644
index 0000000..6914522
--- /dev/null
+++ b/weapp/tests/account-preferences.test.cjs
@@ -0,0 +1,40 @@
+const test=require('node:test');const assert=require('node:assert/strict');const path=require('node:path');const loader=require('./load-module.cjs');
+function page(name,api,wxExtra={},app={}){let definition;const storage=new Map([['userId','user']]);const dialogs=[],toasts=[];const wx={getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),showModal:o=>dialogs.push(o),showToast:o=>toasts.push(o),showLoading(){},hideLoading(){},reLaunch(){},...wxExtra};loader({Page:p=>definition=p,wx,getApp:()=>app,console,Date,Promise,setTimeout,clearTimeout},{'../../utils/request':{api,getImageUrl:v=>v},'../../utils/page-cache':{withDataCache:p=>p},'../../utils/layout':{getLayout:()=>({})}})(path.join(__dirname,'../pages',name));return {p:{...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(v){Object.assign(this.data,v);}},storage,dialogs,toasts};}
+const flush=async()=>{for(let i=0;i<30;i++)await Promise.resolve();};
+test('smoking preference is restored from server on a newly created page, even without local storage',async()=>{
+ let visible=0;const api={checkAuth:async()=>({user:{smoking_card_visible:visible}}),smokingCardVisible:async d=>{visible=d.visible;return {card_visible:visible};}};
+ const first=page('punch/punch.js',api);await first.p.loadSmokingPreference();assert.equal(first.p.data.smokingVisible,false);await first.p.toggleSmoking({detail:{value:true}});assert.equal(visible,1);
+ const installed=page('punch/punch.js',api);await installed.p.loadSmokingPreference();assert.equal(installed.p.data.smokingVisible,true);assert.equal(installed.storage.has('smokingVisible'),false);
+});
+test('failed smoking preference save retains the last server state',async()=>{
+ const e=page('punch/punch.js',{checkAuth:async()=>({user:{smoking_card_visible:0}}),smokingCardVisible:async()=>{throw {error:'offline'};}});await e.p.loadSmokingPreference();await e.p.toggleSmoking({detail:{value:true}});assert.equal(e.p.data.smokingVisible,false);assert.equal(e.p.data.smokingSaving,false);
+});
+test('announcement is dismissible per revision and server disable immediately hides it',async()=>{
+ let data={enabled:true,title:'公告',content:'Hello',revision:'v1'};const e=page('index/index.js',{announcement:async()=>data});await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);e.p.closeAnnouncement();await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);data={...data,content:'Updated',revision:'v2'};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);data={...data,enabled:false};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
+});
+test('account deletion does not submit when either confirmation is cancelled',async()=>{
+ for(const cancelAt of [0,1]){let deleted=0;const e=page('settings/settings.js',{accountDeletionPreview:async()=>({summary:{counts:{files:2,photos:1}},confirmation_token:'proof'}),accountDelete:async()=>{deleted++;}});const pending=e.p.deleteAccount();await flush();assert.equal(e.dialogs.length,1);e.dialogs[0].success({confirm:cancelAt!==0});await flush();if(cancelAt===1)e.dialogs[1].success({confirm:false});await pending;assert.equal(deleted,0);assert.equal(e.p._deletingAccount,false);}
+});
+test('account deletion submits the server proof only after both confirmations and clears the session',async()=>{
+ let payload,cleared=false;const e=page('settings/settings.js',{accountDeletionPreview:async()=>({summary:{counts:{files:2,photos:1}},confirmation_token:'proof'}),accountDelete:async d=>{payload=d;return {success:true};}},{},{finishAccountDeletion(){cleared=true;}});const pending=e.p.deleteAccount();await flush();e.dialogs[0].success({confirm:true});await flush();assert.equal(payload,undefined);e.dialogs[1].success({confirm:true});await pending;assert.equal(payload.confirmed,true);assert.equal(payload.confirmation_token,'proof');assert.equal(cleared,true);
+});
+
+test('announcement login-once mode resets only with a new runtime session and isolates accounts',()=>{
+ const create=()=>loader({}) (path.join(__dirname,'../utils/announcement-session.js'));
+ const session=create(),notice={enabled:true,mode:'login_once',content:'Hello',revision:'v1'};
+ assert.equal(session.shouldShow(notice,'a'),true);session.markShown(notice,'a');assert.equal(session.shouldShow(notice,'a'),false);assert.equal(session.shouldShow(notice,'b'),true);assert.equal(create().shouldShow(notice,'a'),true);
+ session.clear();assert.equal(session.shouldShow(notice,'a'),true);
+});
+test('always mode returns on each homepage entry while off and blank content never display',async()=>{
+ let data={enabled:true,mode:'always',content:'Hello',revision:'v1'};const e=page('index/index.js',{announcement:async()=>data});
+ await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);e.p.closeAnnouncement();await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);
+ data={...data,mode:'off'};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
+ data={...data,mode:'always',content:' \n '};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
+});
+
+test('concurrent homepage notice reads share a request and do not dismiss a fresh popup',async()=>{
+ let done,calls=0;const e=page('index/index.js',{announcement:()=>{calls++;return new Promise(resolve=>done=resolve);}});const a=e.p.loadAnnouncement(),b=e.p.loadAnnouncement();assert.equal(calls,1);done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await Promise.all([a,b]);assert.equal(e.p.data.showAnnouncement,true);
+});
+test('a hidden homepage cannot consume a once-per-login notification',async()=>{
+ let done;const e=page('index/index.js',{announcement:()=>new Promise(resolve=>done=resolve)});const a=e.p.loadAnnouncement();e.p.onHide();done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await a;assert.equal(e.p.data.showAnnouncement,false);e.p._announcementActive=true;const b=e.p.loadAnnouncement();done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await b;assert.equal(e.p.data.showAnnouncement,true);
+});
diff --git a/weapp/tests/album.test.cjs b/weapp/tests/album.test.cjs
index 31b8793..d253dfc 100644
--- a/weapp/tests/album.test.cjs
+++ b/weapp/tests/album.test.cjs
@@ -47,11 +47,11 @@ test('blank edit address uses current user location; a supplied address wins',as
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
});
-function uploadSetup(auth) {
+function uploadSetup(auth, uploadError) {
let definition;const calls=[],modals=[],toasts=[];
const wx={getStorageSync:()=>'',showToast:v=>toasts.push(v),showModal:v=>modals.push(v),showLoading(){},hideLoading(){},switchTab:v=>calls.push(['switchTab',v]),redirectTo:v=>calls.push(['redirectTo',v]),chooseImage:()=>calls.push(['picker'])};
const api={checkAuth:async()=>auth(),storageInfo:async()=>({is_unlimited:true}),albumList:async()=>({items:[]}),albumAdd:async()=>{calls.push(['albumAdd']);return {id:'c224bc8c-0848-442d-abf6-350fab34d669'};},photoAdd:async data=>{calls.push(['photoAdd',data]);return {success:true};}};
- const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
+ const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);if(uploadError)throw uploadError;return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
load(path.join(__dirname,'../pages/album/album.js'));
const page={...definition,data:{...definition.data,albumId:'c224bc8c-0848-442d-abf6-350fab34d669',uploadFilePaths:['photo.jpg'],photoFormList:[{}]},setData(v){Object.assign(this.data,v);},loadData(){calls.push(['reload']);}};
return {page,calls,modals,toasts};
@@ -69,3 +69,11 @@ test('stale demo album is replaced with a real personal album before transferrin
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));e.page.data.albumId=-1;await e.page.uploadAll();
assert.equal(e.calls[0][0],'albumAdd');assert.equal(e.calls[1][0],'upload');assert.equal(e.calls[2][0],'photoAdd');assert.equal(e.calls[2][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
});
+
+test('quota rejection stops an album batch without another upload or a toast covering the upgrade dialog',async()=>{
+ const e=uploadSetup(()=>({logged_in:true}),{code:10001,error:'空间不足'});
+ e.page.data.uploadFilePaths=['one.jpg','two.jpg','three.jpg'];e.page.data.photoFormList=[{},{},{}];
+ await e.page.uploadAll();
+ assert.equal(e.calls.filter(c=>c[0]==='upload').length,1);assert.equal(e.calls.filter(c=>c[0]==='photoAdd').length,0);
+ assert.equal(e.page.data.uploading,false);assert.equal(e.toasts.length,0);assert.equal(e.page.data.uploadFilePaths.length,3);
+});
diff --git a/weapp/tests/auth.test.cjs b/weapp/tests/auth.test.cjs
index b01217c..61d54b3 100644
--- a/weapp/tests/auth.test.cjs
+++ b/weapp/tests/auth.test.cjs
@@ -9,7 +9,7 @@ function setup() {
let app;
const wx = {
getStorageSync: key => storage.get(key), setStorageSync: (key,value) => storage.set(key,value),
- removeStorageSync: key => storage.delete(key), getStorageInfoSync: () => ({keys:[...storage.keys()]}),
+ clearStorageSync:()=>storage.clear(), removeStorageSync: key => storage.delete(key), getStorageInfoSync: () => ({keys:[...storage.keys()]}),
login: options => logins.push(options), request: options => requests.push(options),
getNetworkType: options => options.success({networkType:'wifi'}), onNetworkStatusChange(){},
hideLoading(){}, showToast(){}
@@ -19,7 +19,7 @@ function setup() {
module:{exports:{}}, require: name => loadModule(path.join(__dirname,'../utils', name + '.js'))};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../utils/request.js'),'utf8'),ctx);
const request = ctx.module.exports;
- ctx.require = () => request;
+ ctx.require = name => name === './utils/request' ? request : loadModule(path.join(__dirname,'..', name + '.js'));
ctx.App = definition => {app=definition;};
vm.runInNewContext('(function(){' + fs.readFileSync(path.join(__dirname,'../app.js'),'utf8') + '\n})()',ctx);
return {app,wx,storage,logins,requests,api:request.api};
@@ -70,3 +70,7 @@ test('parallel 401 responses share token refresh and retry at most once',async()
retried[0].success({statusCode:401,data:{error:'still unauthorized'}});retried[1].success({statusCode:200,data:[]});
await failed;await two;assert.equal(env.logins.length,2);
});
+
+test('account deletion clears drafts and credentials and prevents automatic account recreation',async()=>{
+ const env=setup();env.storage.set('token','private');env.storage.set('publishDraft','private draft');env.app.finishAccountDeletion();assert.equal(env.storage.has('publishDraft'),false);assert.equal(env.storage.has('token'),false);assert.equal(env.storage.get('accountDeleted'),true);assert.equal(env.app.globalData.authState,'error');await assert.rejects(env.app.refreshAuth('private'));assert.equal(env.logins.length,0);
+});
diff --git a/weapp/tests/data-cache.test.cjs b/weapp/tests/data-cache.test.cjs
index f5dab19..058acae 100644
--- a/weapp/tests/data-cache.test.cjs
+++ b/weapp/tests/data-cache.test.cjs
@@ -24,7 +24,7 @@ test('identical reads share a request, return independent objects and expire', a
a.items[0].title='formatted';assert.equal(b.items[0].title,'old');
assert.equal((await e.api.diaryList({page:1,page_size:20})).items[0].title,'old');assert.equal(e.requests.length,1);
const different=e.api.diaryList({page:2,page_size:20});await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[]});await different;
- e.advance(45001);const expired=e.api.diaryList({page:1,page_size:20});await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await expired;
+ e.advance(e.cache.TTL+1);const expired=e.api.diaryList({page:1,page_size:20});await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await expired;
});
test('successful mutations invalidate related reads; unrelated and failed writes retain cache',async()=>{
const e=setup();const diary=e.api.diaryList(),album=e.api.albumList();await e.flush();e.respond(0,{items:[]});e.respond(1,[]);await Promise.all([diary,album]);
@@ -50,7 +50,7 @@ test('each upper page observes changes independently and pull refresh bypasses c
await Promise.all([a.loadData(),b.loadData()]);assert.equal(loads,2);
e.cache.mutation('diaryUpdate',{success:true});pending=Promise.all([a.loadData(),b.loadData()]);await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[{id:1}]});await pending;assert.equal(loads,4);
pending=a.onPullDownRefresh();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await pending;
- e.advance(45001);pending=b.loadData();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await pending;
+ e.advance(e.cache.TTL+1);pending=b.loadData();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await pending;
});
test('permission reads remain live and longer profile TTL does not extend list TTL',async()=>{
const e=setup();let p=e.api.periodList();await e.flush();e.respond(0,{cycles:[]});await p;
@@ -99,3 +99,19 @@ test('photo metadata updates invalidate album and photo caches',async()=>{
pending=e.api.photoUpdate({id:1,taken_at:'',location:'公园',activity:'散步'});await e.flush();e.respond(2,{success:true});await pending;
pending=Promise.all([e.api.photos(7),e.api.albumList()]);await e.flush();assert.equal(e.requests.length,5);e.respond(3,[]);e.respond(4,[]);await pending;
});
+
+test('storage reads are reused and upload or confirmed payment invalidates their usage',async()=>{
+ const e=setup();let p=e.api.storageInfo();await e.flush();e.respond(0,{used_bytes:10});await p;
+ await e.api.storageInfo();assert.equal(e.requests.length,1);
+ e.cache.mutation('upload',{success:true});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,2);e.respond(1,{used_bytes:20});await p;
+ e.cache.mutation('storageOrderStatus',{status:'paid'});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{quota_bytes:1024});await p;
+});
+test('remote versions retain unchanged data and invalidate every page after partner or admin changes',async()=>{
+ const e=setup();e.cache.applyRemoteRevision('initial');let loads=0;
+ const page=e.withDataCache({data:{},async load(){loads++;this.data.items=await e.api.diaryList();}},{load:['diaryList']});
+ let p=page.load();await e.flush();e.respond(0,{items:[]});await p;
+ assert.equal(e.cache.applyRemoteRevision('initial'),false);await page.load();assert.equal(e.requests.length,1);
+ assert.equal(e.cache.applyRemoteRevision('partner-change'),true);p=page.onDataChanged();await e.flush();e.respond(1,{items:[{id:1}]});await p;assert.equal(loads,2);assert.equal(page.data.items.items[0].id,1);
+ const stale=e.api.photos(7);await e.flush();e.cache.applyRemoteRevision('admin-change');e.respond(2,{items:[{id:'old'}]});await stale;
+ p=e.api.photos(7);await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[{id:'new'}]});await p;
+});
diff --git a/weapp/tests/file-media.test.cjs b/weapp/tests/file-media.test.cjs
index 5793032..c6940d0 100644
--- a/weapp/tests/file-media.test.cjs
+++ b/weapp/tests/file-media.test.cjs
@@ -9,6 +9,7 @@ function client(extra = {}) {
const calls = [];
const load = createLoader({console, Promise, Date, wx: {
getStorageSync: () => 'token', hideLoading() {},
+ getFileInfo(o){o.success({size:100,digest:'a'.repeat(32)});},
request(o) { calls.push(o); o.success({statusCode:200, data:{success:true}}); }, ...extra
}});
return {...load(path.join(__dirname, '../utils/request.js')), calls};
diff --git a/weapp/tests/login-renewal.test.cjs b/weapp/tests/login-renewal.test.cjs
new file mode 100644
index 0000000..c8e1c32
--- /dev/null
+++ b/weapp/tests/login-renewal.test.cjs
@@ -0,0 +1,45 @@
+const test=require('node:test');
+const assert=require('node:assert/strict');
+const path=require('node:path');
+function setup(){
+ const id='K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU';
+ const storage={userId:'user',coupleId:'couple',token:'token'},reads=[],prompts=[],saves=[],ui=[];
+ const app={globalData:{authState:'ready'}};
+ const load=require('./load-module.cjs')({console:{warn(){},error(){}},Promise,Date,getApp:()=>app,wx:{
+ getStorageSync:key=>storage[key],setStorageSync:(key,value)=>storage[key]=value,
+ getSetting:o=>reads.push(o),requestSubscribeMessage:o=>prompts.push(o),
+ showToast:o=>ui.push(o),showModal:o=>ui.push(o)
+ }},{'./request':{api:{subscribeTemplate:async(data,options)=>{saves.push({data,options});return {success:true}}}}});
+ const helper=load(path.join(__dirname,'../utils/subscribe.js'));
+ const ready=(settings={mainSwitch:true,itemSettings:{[id]:'accept'}})=>reads.at(-1).success({subscriptionsSetting:settings});
+ return {id,storage,reads,prompts,saves,ui,app,helper,ready};
+}
+test('login only reads remembered settings; first real tap renews synchronously once per cold session',async()=>{
+ const e=setup();e.helper.resetLoginRenewal();e.helper.prepareLoginRenewal();
+ assert.equal(e.reads[0].withSubscriptions,true);assert.equal(e.prompts.length,0);assert.equal(e.helper.tryLoginRenewal(),undefined);
+ e.ready();const pending=e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
+ assert.equal(e.helper.tryLoginRenewal(),undefined);
+ e.prompts[0].success({[e.id]:'accept'});await pending;
+ assert.equal(e.saves[0].data.template_type,'notify');assert.equal(e.saves[0].options.silent,true);assert.equal(e.ui.length,0);
+ e.ready();e.helper.prepareLoginRenewal();e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
+ e.helper.resetLoginRenewal();e.helper.prepareLoginRenewal();e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,2);
+});
+test('unknown permission, not remembered, rejected, banned and disabled main switch never request subscription',()=>{
+ for(const settings of [{},{mainSwitch:true},{mainSwitch:true,itemSettings:{}},{mainSwitch:true,itemSettings:{bad:'accept'}},{mainSwitch:true,itemSettings:{K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU:'reject'}},{mainSwitch:false,itemSettings:{K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU:'accept'}}]){
+ const e=setup();e.helper.prepareLoginRenewal();e.ready(settings);e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);assert.equal(e.ui.length,0);
+ }
+});
+test('unbound, locally disabled or unauthenticated users never auto-renew',()=>{
+ for(const change of [e=>e.storage.coupleId='',e=>e.storage.subscribed_notify=false,e=>e.app.globalData.authState='loading']){
+ const e=setup();e.helper.prepareLoginRenewal();e.ready();change(e);e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
+ }
+});
+test('settings failures and stale callbacks fail closed',()=>{
+ const e=setup();e.helper.prepareLoginRenewal();e.reads[0].fail();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
+ e.helper.prepareLoginRenewal();const stale=e.reads.at(-1);e.helper.resetLoginRenewal();stale.success({subscriptionsSetting:{mainSwitch:true,itemSettings:{[e.id]:'accept'}}});e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
+ e.helper.prepareLoginRenewal();e.ready();e.helper.prepareLoginRenewal();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
+});
+test('renewal failures remain silent and do not retry repeatedly during the same session',async()=>{
+ const e=setup();e.helper.prepareLoginRenewal();e.ready();const pending=e.helper.tryLoginRenewal();e.prompts[0].fail({errCode:20001});await pending;
+ assert.equal(e.ui.length,0);e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
+});
diff --git a/weapp/tests/navigation.test.cjs b/weapp/tests/navigation.test.cjs
index ce7d6ea..e0ce211 100644
--- a/weapp/tests/navigation.test.cjs
+++ b/weapp/tests/navigation.test.cjs
@@ -25,7 +25,7 @@ test('native custom tabs retain the three main pages; pages never mount a duplic
test('component selects the main page, hides on child routes and avoids reloading its current tab',()=>{
let definition,route='pages/profile/profile',pageData={};const navigations=[];
vm.runInNewContext(fs.readFileSync(path.join(root,'custom-tab-bar/index.js'),'utf8'),{
- Component:c=>definition=c,require:()=>({tabForPage,paths}),getCurrentPages:()=>[{route,data:pageData}],wx:{switchTab:o=>{navigations.push(o.url);o.complete();},reLaunch(){assert.fail('normal tab switching must retain pages');}}
+ Component:c=>definition=c,require:name=>name.includes('subscribe')?{tryLoginRenewal(){}}:({tabForPage,paths}),getCurrentPages:()=>[{route,data:pageData}],wx:{switchTab:o=>{navigations.push(o.url);o.complete();},reLaunch(){assert.fail('normal tab switching must retain pages');}}
});
const component={...definition.methods,data:{...definition.data},setData(v){Object.assign(this.data,v);}};
definition.lifetimes.attached.call(component);assert.equal(component.data.active,'profile');
diff --git a/weapp/tests/request.test.cjs b/weapp/tests/request.test.cjs
index ed97c88..0ece809 100644
--- a/weapp/tests/request.test.cjs
+++ b/weapp/tests/request.test.cjs
@@ -19,7 +19,7 @@ test('GET business parameters and POST bodies use clean paths with auth', async
});
test('multipart upload retains action and token when Android omits headers',async()=>{
let upload;
- const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
+ const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',getFileInfo(o){o.success({size:100,digest:'a'.repeat(32)});},request(o){o.success({statusCode:200,data:{success:true}});},compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
await uploadFileCompat({url:'https://we.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
assert.equal(upload.url,'https://we.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
assert.equal(upload.formData.action,'uploadPartnerAvatar');
@@ -59,3 +59,27 @@ test('unbound newcomer loads homepage records without requesting couple-only pet
assert.equal(page.data.homeError,false);assert.equal(page.data.statsError,false);assert.equal(page.data.statsLoading,false);
assert.equal(page.data.recentDiary.length,0);assert.equal(page.data.stats.pet,0);
});
+
+test('smoking visibility page uses the real request adapter and restores the persisted response',async()=>{
+ let definition,visible=1;const calls=[];
+ const wx={getStorageSync:()=> 'test-token',showToast(){},request(o){
+ calls.push(o);
+ if(o.url.endsWith('/checkAuth'))o.success({statusCode:200,data:{user:{smokingCardVisible:visible}}});
+ else if(o.url.endsWith('/smokingCardVisible')){visible=o.data.visible;o.success({statusCode:200,data:{success:true,cardVisible:visible}});}
+ else throw new Error('Unexpected request: '+o.url);
+ }};
+ require('./load-module.cjs')({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/page-cache':{withDataCache:p=>p}})(path.join(__dirname,'../pages/punch/punch.js'));
+ const makePage=()=>({...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(p){Object.assign(this.data,p);}});
+ const page=makePage();await page.loadSmokingPreference();assert.equal(page.data.smokingVisible,true);
+ await page.toggleSmoking({detail:{value:false}});assert.equal(visible,0);assert.equal(page.data.smokingVisible,false);
+ const save=calls.find(c=>c.url.endsWith('/smokingCardVisible'));assert.equal(save.method,'POST');assert.equal(save.data.visible,0);assert.equal(save.header.Authorization,'Bearer test-token');
+ const reopened=makePage();await reopened.loadSmokingPreference();assert.equal(reopened.data.smokingVisible,false);
+ await reopened.toggleSmoking({detail:{value:true}});assert.equal(visible,1);assert.equal(reopened.data.smokingVisible,true);
+});
+
+test('silent subscription persistence never hides a business spinner or shows a network toast',async()=>{
+ const ui=[];
+ const {api}=load('utils/request.js',{getStorageSync:()=> 'token',hideLoading:()=>ui.push('hide'),showToast:()=>ui.push('toast'),request:o=>o.fail({errMsg:'offline'})});
+ await assert.rejects(api.subscribeTemplate({template_type:'notify',template_id:'template'},{silent:true}));
+ assert.equal(ui.length,0);
+});
diff --git a/weapp/tests/storage-tiers.test.cjs b/weapp/tests/storage-tiers.test.cjs
index ebd76a7..4cc536f 100644
--- a/weapp/tests/storage-tiers.test.cjs
+++ b/weapp/tests/storage-tiers.test.cjs
@@ -13,7 +13,7 @@ test('finite paid space uses its quota, zero is respected, unlimited is explicit
test('storage page renders 1-cent plans and custom month/capacity values',async()=>{
let definition;
const info={freeBytes:200*1024*1024,quotaBytes:1024*1024*1024,usedBytes:512*1024*1024,isVip:true,isUnlimited:false,vipExpireAt:'2026-12-02',plans:[{key:'custom',name:'测试套餐',priceCents:1,quotaMb:1024,months:3}]};
- const load=loader({Page(p){definition=p},wx:{},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
+ const load=loader({Page(p){definition=p},wx:{getStorageSync:()=> 'user'},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
load(path.join(__dirname,'../pages/storage/storage.js'));
const page={...definition,data:{...definition.data},setData(d){Object.assign(this.data,d)}};
await page.loadInfo();
diff --git a/weapp/tests/subscribe.test.cjs b/weapp/tests/subscribe.test.cjs
index 7b8753a..42770a3 100644
--- a/weapp/tests/subscribe.test.cjs
+++ b/weapp/tests/subscribe.test.cjs
@@ -1,7 +1,7 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
-const template='tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8';
+const template='K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU';
function setup(save){
const storage={coupleId:10},calls=[];
const wx={getStorageSync:key=>storage[key],setStorageSync:(key,v)=>storage[key]=v,requestSubscribeMessage:o=>calls.push(o)};
@@ -24,18 +24,6 @@ test('failed persistence never reports successful subscription and a later tap c
fail=false;pending=e.helper.requestSubscribe('notify');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});
assert.equal((await pending).notify,true);
});
-test('each subsequent user action can replenish authorization; denial does not prevent later attempts',async()=>{
- const e=setup(async()=>({success:true}));
- let pending=e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,1);e.calls[0].success({[template]:'reject'});await pending;
- pending=e.helper.requestSubscribeAfterAction('photo');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});await pending;
- pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,3);e.calls[2].fail(Error('cancelled'));await pending;
- pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,4);e.calls[3].success({[template]:'accept'});await pending;
-});
-test('explicitly disabled notifications and unbound users do not prompt during business actions',async()=>{
- const e=setup(async()=>({success:true}));e.storage.subscribed_notify=false;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
- delete e.storage.subscribed_notify;delete e.storage.coupleId;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
-});
-
test('manual renewal requests authorization again without disabling existing notifications',async()=>{
let page, calls=0, cancelled=0, complete;
const loader=require('./load-module.cjs')({Page:p=>page=p,wx:{showToast(){}}},{
diff --git a/weapp/tests/upload-quota.test.cjs b/weapp/tests/upload-quota.test.cjs
new file mode 100644
index 0000000..b327f4c
--- /dev/null
+++ b/weapp/tests/upload-quota.test.cjs
@@ -0,0 +1,46 @@
+const test=require('node:test');
+const assert=require('node:assert/strict');
+const path=require('node:path');
+function setup({preflightQuota=false,uploadQuota=false,networkRetry=false,metadataFailure=false}={}){
+ const checks=[],uploads=[],modals=[],navigation=[];
+ const load=require('./load-module.cjs')({console,Date,Promise,wx:{
+ getStorageSync:()=> 'token',hideLoading(){},showLoading(){},showToast(){},
+ compressImage(o){o.success({tempFilePath:'compressed.jpg'});},
+ getFileInfo(o){if(metadataFailure)return o.fail();o.success({size:o.filePath==='compressed.jpg'?100:200,digest:'a'.repeat(32)});},
+ request(o){checks.push(o);o.success({statusCode:preflightQuota?403:200,data:preflightQuota?{error:'空间不足',code:10001}:{success:true}});},
+ uploadFile(o){uploads.push(o);if(networkRetry&&uploads.length===1)return o.fail({errMsg:'network'});o.success({statusCode:uploadQuota?403:200,data:JSON.stringify(uploadQuota?{error:'空间不足',code:10001}:{success:true,fileId:'file-id'})});},
+ showModal(o){modals.push(o);},navigateTo(o){navigation.push(o.url);}
+ }});
+ const client=load(path.join(__dirname,'../utils/request.js'));
+ return {...client,checks,uploads,modals,navigation};
+}
+const options={url:'https://we.sundynix.cn/api/upload',filePath:'original.jpg'};
+test('preflight checks the compressed bytes before any upload',async()=>{
+ const c=setup();await c.uploadFileCompat(options);
+ assert.equal(c.checks[0].url,'https://we.sundynix.cn/api/uploadPreflight');
+ assert.equal(c.checks[0].data.size,100);assert.equal(c.checks[0].data.md5,'a'.repeat(32));
+ assert.equal(c.uploads[0].filePath,'compressed.jpg');
+});
+test('preflight quota rejection blocks bytes, prompts once and offers purchase',async()=>{
+ const c=setup({preflightQuota:true});
+ await assert.rejects(c.uploadFileCompat(options),e=>e.code===10001);
+ assert.equal(c.uploads.length,0);assert.equal(c.checks.length,1);assert.equal(c.modals.length,1);
+ assert.equal(c.modals[0].title,'存储空间不足');c.modals[0].success({confirm:true});
+ assert.deepEqual(c.navigation,['/pages/storage/storage']);
+});
+test('authoritative backend quota rejection after a successful preflight also prompts',async()=>{
+ const c=setup({uploadQuota:true});await assert.rejects(c.uploadFileCompat(options),e=>e.code===10001);
+assert.equal(c.uploads.length,1);assert.equal(c.modals.length,1);
+ c.modals[0].success({confirm:false});assert.equal(c.navigation.length,0);
+});
+test('avatar, partner avatar and album uploads all use quota preflight and show one modal on rejection',async()=>{
+ for(const run of [c=>c.api.uploadAvatar('original.jpg'),c=>c.api.uploadPartnerAvatar('original.jpg','partner'),c=>c.api.photoUpload('original.jpg','album')]){
+ const c=setup({uploadQuota:true});await assert.rejects(run(c),e=>e.code===10001);
+ assert.equal(c.checks.length,1);assert.equal(c.uploads.length,1);assert.equal(c.modals.length,1);
+ }
+});
+test('network fallback rechecks the original file size; unreadable files never upload',async()=>{
+ const c=setup({networkRetry:true});await c.uploadFileCompat(options);
+ assert.deepEqual(c.checks.map(o=>o.data.size),[100,200]);assert.equal(c.uploads[1].filePath,'original.jpg');
+ const bad=setup({metadataFailure:true});await assert.rejects(bad.uploadFileCompat(options));assert.equal(bad.uploads.length,0);
+});
diff --git a/weapp/utils/announcement-session.js b/weapp/utils/announcement-session.js
new file mode 100644
index 0000000..a2e794c
--- /dev/null
+++ b/weapp/utils/announcement-session.js
@@ -0,0 +1,14 @@
+// Memory only: a cold start begins a new login session. Never persist this in storage.
+const seen = new Map();
+function shouldShow(notice, userId) {
+ if (!notice || !notice.enabled || !String(notice.content || '').trim()) return false;
+ const mode = notice.mode || 'login_once';
+ if (mode === 'off') return false;
+ if (mode === 'always') return true;
+ return mode === 'login_once' && seen.get(String(userId || '')) !== notice.revision;
+}
+function markShown(notice, userId) {
+ if (notice && (notice.mode || 'login_once') === 'login_once') seen.set(String(userId || ''), notice.revision);
+}
+function clear() { seen.clear(); }
+module.exports = { shouldShow, markShown, clear };
diff --git a/weapp/utils/data-cache.js b/weapp/utils/data-cache.js
index f142be8..0b98191 100644
--- a/weapp/utils/data-cache.js
+++ b/weapp/utils/data-cache.js
@@ -1,7 +1,8 @@
// 仅保存本次进程内的数据,不写入手机本地存储。
-const TTL = 45000;
+const TTL = 300000;
const policies = {
- coupleInfo: 300000, getBgConfig: 300000,
+ coupleInfo: TTL, getBgConfig: TTL, myInfo: TTL, storageInfo: TTL, announcement: TTL,
+ checkAuth: 30000, unbindStatus: 30000,
timelineList: TTL, timelineDetail: TTL, albumList: TTL, photos: TTL,
diaryList: TTL, diaryDetail: TTL, diaryCommentList: TTL,
songsList: TTL, checkinList: TTL, checkinStats: TTL, checkinDetail: TTL,
@@ -18,12 +19,20 @@ let entries = new Map();
let versions = {};
let scope = '';
let epoch = 0;
+let remoteRevision;
function currentScope() {
const next = String(wx.getStorageSync('userId') || '') + ':' + String(wx.getStorageSync('coupleId') || '');
if (next !== scope) { scope = next; clear(); }
return scope;
}
-function clear() { entries.clear(); versions = {}; epoch++; }
+function clear() { entries.clear(); versions = {}; remoteRevision = undefined; epoch++; }
+function applyRemoteRevision(revision) {
+ currentScope();
+ if (!revision) return false;
+ if (remoteRevision === undefined) { remoteRevision = revision; return false; }
+ if (remoteRevision === revision) return false;
+ clear(); remoteRevision = revision; return true;
+}
function signature(actions) {
currentScope();
return epoch + ':' + actions.map(action => action + ':' + (versions[action] || 0)).join('|');
@@ -82,7 +91,10 @@ function mutation(action, result) {
else if (/^pet(Adopt|DailyAction|Rename)$/.test(action)) group = 'pet';
else if (/^(setBgImage|deleteBgImage)$/.test(action)) group = 'background';
if (group) invalidate(groups[group].concat(['activityLogList']));
+ if (group === 'couple') invalidate(['myInfo','checkAuth']);
+ if (action === 'smokingCardVisible') invalidate(['checkAuth','myInfo']);
+ if (/^(upload|photoUpload|uploadAvatar|uploadPartnerAvatar|setBgImage|deleteBgImage|setAlbumCover|photoDelete|albumDelete|accountDelete)$/.test(action) || (action === 'storageOrderStatus' && result && result.status === 'paid')) invalidate(['storageInfo']);
// 展示作者资料的列表也需要更新头像/昵称。
if (group === 'couple') invalidate(groups.timeline.concat(groups.diary, groups.checkin));
}
-module.exports = { read, invalidate, mutation, clear, signature, policies, TTL };
+module.exports = { read, invalidate, mutation, clear, signature, policies, TTL, applyRemoteRevision };
diff --git a/weapp/utils/page-cache.js b/weapp/utils/page-cache.js
index 31036db..66e3e38 100644
--- a/weapp/utils/page-cache.js
+++ b/weapp/utils/page-cache.js
@@ -17,12 +17,12 @@ function withDataCache(definition, loaders) {
const ttl = Math.min(...dependencies.map(action => cache.policies[action] || cache.TTL));
if (previous && previous.signature === signature && previous.key === key) {
if (previous.pending) return previous.pending;
- if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) return Promise.resolve();
+ if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) return Promise.resolve();
}
const record = { signature, key, time: 0 };
this._dataLoads[name] = record;
record.pending = Promise.resolve().then(() => load.apply(this, args)).then(result => {
- if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) {
+ if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) {
record.time = Date.now();
if (name !== 'loadComments') this._hasCachedData = true;
}
@@ -32,6 +32,9 @@ function withDataCache(definition, loaders) {
return record.pending;
};
});
+ definition.onDataChanged = function() {
+ return Promise.all(Object.keys(loaders).map(name => this[name]())).catch(error => { console.warn('刷新变动数据失败', error); });
+ };
['onPullDownRefresh', 'onRefresh'].forEach(name => {
const refresh = definition[name];
if (refresh) definition[name] = function(...args) {
diff --git a/weapp/utils/request.js b/weapp/utils/request.js
index fc29de1..84d3aec 100644
--- a/weapp/utils/request.js
+++ b/weapp/utils/request.js
@@ -69,16 +69,22 @@ var STORAGE_QUOTA_EXCEEDED = 10001;
function isStorageQuotaError(data) {
return !!(data && data.code === STORAGE_QUOTA_EXCEEDED);
}
+var storageModalOpen = false;
function maybeRedirectStorage(data) {
if (!isStorageQuotaError(data)) return false;
+ if (data._storagePromptShown || storageModalOpen) return true;
+ data._storagePromptShown = true;
+ storageModalOpen = true;
+ try { wx.hideLoading(); } catch(e) {}
wx.showModal({
title: '存储空间不足',
- content: '存储空间已用完,请选择更大容量的套餐后继续上传。',
+ content: '剩余存储空间不足以保存这张图片,请购买更大容量的套餐后继续上传。',
confirmText: '去升级',
cancelText: '暂不',
success: function (res) {
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
- }
+ },
+ complete: function() { storageModalOpen = false; }
});
return true;
}
@@ -96,32 +102,33 @@ async function waitForAuth() {
var app = authApp();
if (app && app.ensureAuth) await app.ensureAuth();
}
-async function request(action, data = null, method = 'POST') {
+async function request(action, data = null, method = 'POST', options = {}) {
const name = camelName(action);
await waitForAuthIfNeeded(name);
- if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method));
- const result = await authenticatedRequest(action, data, method);
+ if (method === 'GET' && options.cache === false) return authenticatedRequest(action, data, method, options);
+ if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method, options));
+ const result = await authenticatedRequest(action, data, method, options);
dataCache.mutation(name, result);
return result;
}
async function waitForAuthIfNeeded(action) {
if (action !== 'wechatLogin') await waitForAuth();
}
-async function authenticatedRequest(action, data = null, method = 'POST') {
- if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method);
+async function authenticatedRequest(action, data = null, method = 'POST', options = {}) {
+ if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method, options);
await waitForAuth();
var token = wx.getStorageSync('token');
try {
- return await sendRequest(action, data, method);
+ return await sendRequest(action, data, method, options);
} catch (error) {
var app = authApp();
if (error._statusCode !== 401 || !app || !app.refreshAuth) throw error;
await app.refreshAuth(token);
// 每个业务请求最多重试一次,防止 401 导致无限登录。
- return sendRequest(action, data, method);
+ return sendRequest(action, data, method, options);
}
}
-function sendRequest(action, data = null, method = 'POST') {
+function sendRequest(action, data = null, method = 'POST', options = {}) {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token');
const header = { 'Content-Type': 'application/json' };
@@ -161,16 +168,20 @@ function sendRequest(action, data = null, method = 'POST') {
const previous = String(wx.getStorageSync('coupleId') || '');
if (next !== previous) { wx.setStorageSync('coupleId', next); dataCache.clear(); const app = typeof getApp === 'function' && getApp(); if(app && app.onRelationChanged) app.onRelationChanged(); }
}
- try { wx.hideLoading(); } catch(e) {}
+ if (!options.silent) { try { wx.hideLoading(); } catch(e) {} }
if (res.statusCode >= 400 || (res.data && res.data.error)) {
reject({ ...fromWire(res.data), _statusCode: res.statusCode });
} else {
- resolve(fromWire(res.data));
+ const result = fromWire(res.data);
+ if (camelName(action) === 'storageOrderStatus') dataCache.mutation('storageOrderStatus', result);
+ resolve(result);
}
},
fail: (err) => {
- try { wx.hideLoading(); } catch(e) {}
- wx.showToast({ title: '网络请求失败', icon: 'none' });
+ if (!options.silent) {
+ try { wx.hideLoading(); } catch(e) {}
+ wx.showToast({ title: '网络请求失败', icon: 'none' });
+ }
reject(err);
}
});
@@ -179,54 +190,17 @@ function sendRequest(action, data = null, method = 'POST') {
// ========== API 方法封装 ==========
const api = {
+ announcement: () => request('announcement', null, 'GET'),
+ accountDeletionPreview: () => request('accountDeletionPreview', null, 'POST'),
+ accountDelete: (data) => request('accountDelete', data),
// 用户相关
wechatLogin: (code, region) => request('wechatLogin', { code: code, region: region || '' }),
- checkAuth: () => request('checkAuth', null, 'GET'),
+ checkAuth: (options) => request('checkAuth', null, 'GET', options),
logout: () => request('logout', null, 'POST'),
updateProfile: (data) => request('updateProfile', data),
updateLocation: (location) => request('updateLocation', { location: location }),
updateIpLocation: (data) => request('updateIpLocation', data || {}, 'POST'),
- _uploadFileDirect: async function(url, filePath, name, formData) {
- await waitForAuth();
- return new Promise((resolve, reject) => {
- const token = wx.getStorageSync('token');
- var uploadUrl = url;
- var sep = uploadUrl.indexOf('?') > -1 ? '&' : '?';
- uploadUrl += sep + 'token=' + encodeURIComponent(token || '');
- var finalFormData = toWire(formData || {});
- finalFormData.token = token || '';
- // 从 URL 中提取 action 参数并添加到 formData(解决非开发者模式下 URL 参数被剥离的问题)
- var actionMatch = url.match(/\/api\/([^/?]+)|[?&]action=([^&]+)/);
- if (actionMatch) finalFormData.action = decodeURIComponent(actionMatch[1] || actionMatch[2]);
- var finalHeader = {};
- if (token) finalHeader['Authorization'] = 'Bearer ' + token;
- function doUpload(fp) {
- return new Promise((res, rej) => {
- wx.uploadFile({
- url: uploadUrl,
- filePath: fp,
- name: name || 'file',
- header: finalHeader,
- formData: finalFormData,
- timeout: 120000,
- success: (r) => {
- var parsed = parseUploadResponse(r.data);
- if (parsed) res(parsed);
- else rej({ error: '解析响应失败', raw: r.data });
- },
- fail: (e) => rej({ error: '网络异常', errMsg: e.errMsg })
- });
- });
- }
- compressForUpload(filePath).then((compressedPath) => {
- doUpload(compressedPath).then(resolve).catch(() => {
- doUpload(filePath).then(resolve).catch(reject);
- });
- }).catch(() => {
- doUpload(filePath).then(resolve).catch(reject);
- });
- });
- },
+ _uploadFileDirect: (url, filePath, name, formData) => uploadFileCompat({url, filePath, name, formData}),
uploadAvatar: async (filePath) => {
wx.showLoading({ title: '上传中...' });
@@ -239,12 +213,11 @@ const api = {
);
wx.hideLoading();
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
- maybeRedirectStorage(data);
- wx.showToast({ title: formatUploadError(data), icon: 'none' });
+ if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
- wx.showToast({ title: formatUploadError(e), icon: 'none' });
+ if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -260,12 +233,11 @@ const api = {
);
wx.hideLoading();
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
- maybeRedirectStorage(data);
- wx.showToast({ title: formatUploadError(data), icon: 'none' });
+ if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
- wx.showToast({ title: formatUploadError(e), icon: 'none' });
+ if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -303,67 +275,16 @@ const api = {
photoUpload: async (filePath, albumId) => {
wx.showLoading({ title: '上传中...' });
try {
- await waitForAuth();
- // 直接使用 wx.uploadFile,兼容安卓端不发送自定义 header 的问题
- const token = wx.getStorageSync('token');
- const uploadUrl = API_BASE + '/photoUpload?token=' + encodeURIComponent(token || '');
- const baseFormData = { albumId: albumId, token: token || '', action: 'photoUpload' };
- const data = await new Promise((resolve, reject) => {
- // 先压缩图片
- compressForUpload(filePath).then((compressedPath) => {
- wx.uploadFile({
- url: uploadUrl,
- filePath: compressedPath,
- name: 'photos',
- formData: baseFormData,
- timeout: 120000,
- success: (res) => {
- var parsed = parseUploadResponse(res.data);
- if (parsed) resolve(parsed);
- else reject({ error: '解析响应失败', raw: res.data });
- },
- fail: (err) => {
- // 压缩后上传失败,尝试原路径
- wx.uploadFile({
- url: uploadUrl,
- filePath: filePath,
- name: 'photos',
- formData: baseFormData,
- timeout: 120000,
- success: (r) => {
- var parsed = parseUploadResponse(r.data);
- if (parsed) resolve(parsed);
- else reject({ error: '解析响应失败', raw: r.data });
- },
- fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg })
- });
- }
- });
- }).catch(() => {
- // 压缩失败直接上传原路径
- wx.uploadFile({
- url: uploadUrl,
- filePath: filePath,
- name: 'photos',
- formData: baseFormData,
- timeout: 120000,
- success: (r) => {
- var parsed = parseUploadResponse(r.data);
- if (parsed) resolve(parsed);
- else reject({ error: '解析响应失败', raw: r.data });
- },
- fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg })
- });
- });
+ const data = await uploadFileCompat({
+ url: API_BASE + '/photoUpload', filePath, name: 'photos', formData: {albumId}
});
wx.hideLoading();
if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; }
- maybeRedirectStorage(data);
- wx.showToast({ title: formatUploadError(data), icon: 'none' });
+ if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
- wx.showToast({ title: formatUploadError(e), icon: 'none' });
+ if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -388,6 +309,7 @@ const api = {
// 打卡
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || "" }, "GET"),
smokingAction: (data) => request("smokingAction", data),
+ smokingCardVisible: (data) => request("smokingCardVisible", data),
checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
checkinAdd: (data) => request('checkinAdd', data),
checkinDo: (data) => request('checkinDo', data),
@@ -408,7 +330,7 @@ const api = {
activityLogList: (data) => request('activityLogList', data, 'GET'),
// 订阅消息
- subscribeTemplate: (data) => request('subscribeTemplate', data),
+ subscribeTemplate: (data, options) => request('subscribeTemplate', data, 'POST', options),
unsubscribeTemplate: (data) => request('unsubscribeTemplate', data),
mySubscribeTemplates: () => request('mySubscribeTemplates', null, 'GET'),
@@ -488,7 +410,18 @@ async function uploadFileCompat(options) {
uploadUrl += sep + 'token=' + encodeURIComponent(token);
}
- function doUpload(fp) {
+ async function doUpload(fp) {
+ // Hash and measure the compressed file that will actually be uploaded.
+ const info = await new Promise((resolve, reject) => wx.getFileInfo({
+ filePath: fp, digestAlgorithm: 'md5', success: resolve,
+ fail: () => reject({error:'读取文件信息失败,请重新选择图片'})
+ }));
+ try {
+ await request('uploadPreflight', {size: info.size, md5: info.digest});
+ } catch(error) {
+ maybeRedirectStorage(error);
+ throw error;
+ }
return new Promise((resolve, reject) => {
wx.uploadFile({
url: uploadUrl,
@@ -500,6 +433,7 @@ async function uploadFileCompat(options) {
success: (res) => {
var data = parseUploadResponse(res.data);
if (data) {
+ if (maybeRedirectStorage(data)) { reject(data); return; }
if (res.statusCode < 400 && !data.error && (data.success || data.path || data.uploaded)) {
dataCache.mutation(actionMatch ? decodeURIComponent(actionMatch[1] || actionMatch[2]) : '', data);
}
@@ -509,24 +443,21 @@ async function uploadFileCompat(options) {
}
},
fail: (err) => {
- reject({ error: '网络异常', errMsg: err.errMsg });
+ reject({ error: '网络异常', errMsg: err.errMsg, _uploadNetworkError: true });
}
});
});
}
- return new Promise((resolve, reject) => {
- // 先压缩图片,确保 Android 临时文件路径可读
- compressForUpload(filePath).then((compressedPath) => {
- doUpload(compressedPath).then(resolve).catch(() => {
- // 压缩后上传失败,尝试原路径
- doUpload(filePath).then(resolve).catch(reject);
- });
- }).catch(() => {
- // 压缩失败时尝试直接上传
- doUpload(filePath).then(resolve).catch(reject);
- });
- });
+ const compressedPath = await compressForUpload(filePath);
+ try {
+ return await doUpload(compressedPath);
+ } catch(error) {
+ // Business/preflight failures never retry. A network retry with the original
+ // file must pass a fresh quota check because it may be larger.
+ if (error._uploadNetworkError && compressedPath !== filePath) return doUpload(filePath);
+ throw error;
+ }
}
module.exports = { api, getImageUrl, API_BASE, uploadFileCompat, compressForUpload };
diff --git a/weapp/utils/subscribe.js b/weapp/utils/subscribe.js
index 50253d2..8ef4246 100644
--- a/weapp/utils/subscribe.js
+++ b/weapp/utils/subscribe.js
@@ -6,9 +6,9 @@
*/
const { api } = require('./request');
-// 统一使用「每日记录提醒」模板,所有操作都通过这个模板通知
+// 操作和打卡共用同一模板、同一份一次性授权。
const SUBSCRIBE_TEMPLATES = {
- notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
+ notify: 'K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU',
};
// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。
@@ -19,7 +19,7 @@ let _pendingSubscribe = null;
* @param {string|string[]} templateTypes - 模板类型或模板类型数组
* @returns {Promise