From 31eae87a63603a66eb227ce19ae459758047633d Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sun, 11 Oct 2026 10:32:30 +0800 Subject: [PATCH] Fix subscription renewal and partner notification delivery --- past-go/internal/handler/misc.go | 4 +- past-go/internal/notify/notify.go | 76 ++++++++++---------- past-go/internal/notify/notify_test.go | 68 ++++++++++++++++++ past-go/internal/wechat/subscribe_test.go | 32 ++++++++- past-go/internal/wechat/wechat.go | 42 ++++++++++- weapp/pages/album/album.js | 2 +- weapp/pages/checkin-detail/checkin-detail.js | 11 +-- weapp/pages/profile/profile.js | 2 +- weapp/pages/publish/publish.js | 4 +- weapp/pages/settings/settings.js | 18 +++++ weapp/pages/settings/settings.wxml | 11 +-- weapp/tests/subscribe.test.cjs | 53 ++++++++++++++ weapp/utils/subscribe.js | 74 ++++++------------- 13 files changed, 282 insertions(+), 115 deletions(-) create mode 100644 past-go/internal/notify/notify_test.go create mode 100644 weapp/tests/subscribe.test.cjs diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go index f8f8d78..327389e 100644 --- a/past-go/internal/handler/misc.go +++ b/past-go/internal/handler/misc.go @@ -258,8 +258,8 @@ func subscribeTemplate(c *httpapi.Ctx) error { if !store.Truthy(templateType) || !store.Truthy(templateID) { return c.Err(400, "缺少参数") } - if _, ok := c.App.Wechat.Templates()[templateType]; !ok { - return c.Err(400, "不支持的模板类型") + if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID { + return c.Err(400, "订阅模板与服务端配置不一致") } if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE template_id = VALUES(template_id)", uid, templateID, templateType); err != nil { return err diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go index cca08b5..f8194ac 100644 --- a/past-go/internal/notify/notify.go +++ b/past-go/internal/notify/notify.go @@ -63,28 +63,37 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) { } } -// Once 执行一次扫描;force=true 时不限时间且忽略重试上限(对应 --force) -func Once(db *store.DB, wx *wechat.Client, force bool) { +type Sender interface { + SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool +} + +// force 只放宽时间和次数限制,仍检查成功记录、关系和分享权限。 +func Once(db *store.DB, wx Sender, force bool) { ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",") - args := make([]any, 0, len(actionTypes)+1) - for _, a := range actionTypes { - args = append(args, a) + args := make([]any, 0, len(actionTypes)+3) + for _, action := range actionTypes { + args = append(args, action) } - var sql string - if force { - sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl) AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20" - } else { - sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.created_at >= ? AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl) AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20" - args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05")) + query := `SELECT 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' + 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') + AND (a.action_type NOT IN ('period_add','period_sex_add') OR COALESCE(u.period_visible,0)<>0)` + if !force { + query += ` AND a.created_at >= ? AND (SELECT COUNT(*) FROM subscribe_message_logs sl WHERE sl.activity_id=a.id AND sl.to_user_id=recipient.id) < ?` + args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05"), maxRetries) } - activities, err := db.Query(sql, args...) + query += " ORDER BY a.created_at DESC, a.id DESC LIMIT ?" + args = append(args, maxBatch) + activities, err := db.Query(query, args...) if err != nil { slog.Error("cron_notify 查询失败", "err", err) return } - if len(activities) == 0 { - return - } processed, success, fail, skip := 0, 0, 0, 0 for _, a := range activities { activityID, userID, actionType := a.Int("id"), a.Int("user_id"), a.Str("action_type") @@ -92,19 +101,21 @@ func Once(db *store.DB, wx *wechat.Client, force bool) { if nickname == "" { nickname = "你的TA" } - partnerID := partnerUserID(db, userID) - if partnerID == 0 { + partnerID := a.Int("to_user_id") + // 扫描后共享开关或关系可能改变;实际发送前再次检查。 + allowed, err := db.QueryOne(`SELECT u.period_visible FROM users u JOIN couples cp ON cp.id=u.couple_id AND cp.status='active' + JOIN users recipient ON recipient.id=? AND recipient.couple_id=cp.id + WHERE u.id=? AND cp.id=? AND u.id IN (cp.user1_id,cp.user2_id) AND recipient.id IN (cp.user1_id,cp.user2_id) AND recipient.id<>u.id`, partnerID, userID, a.Int("couple_id")) + if err != nil || allowed == nil || ((actionType == "period_add" || actionType == "period_sex_add") && allowed.Int("period_visible") == 0) { skip++ continue } - cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, partnerID) - retry := int64(0) - if cnt != nil { - retry = cnt.Int("cnt") - } - if retry >= maxRetries && !force { - skip++ - continue + if !force { + count, err := db.QueryOne("SELECT COUNT(*) as cnt FROM subscribe_message_logs WHERE activity_id=? AND to_user_id=?", activityID, partnerID) + if err != nil || count == nil || count.Int("cnt") >= maxRetries { + skip++ + continue + } } // buildNotifyData date := a.Str("created_at") @@ -137,18 +148,3 @@ func Once(db *store.DB, wx *wechat.Client, force bool) { } slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip) } - -func partnerUserID(db *store.DB, userID int64) int64 { - u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID) - if u == nil || u.Int("couple_id") == 0 { - return 0 - } - cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id")) - if cp == nil { - return 0 - } - if cp.Int("user1_id") == userID { - return cp.Int("user2_id") - } - return cp.Int("user1_id") -} diff --git a/past-go/internal/notify/notify_test.go b/past-go/internal/notify/notify_test.go new file mode 100644 index 0000000..436afbf --- /dev/null +++ b/past-go/internal/notify/notify_test.go @@ -0,0 +1,68 @@ +package notify + +import ( + "database/sql/driver" + "past/internal/store" + "past/internal/testdb" + "strings" + "testing" +) + +type testSender struct { + calls int + to, from int64 +} + +func (s *testSender) SendSubscribeMessage(to int64, typ string, data map[string]string, page string, activity, from int64) bool { + s.calls++ + s.to, s.from = to, from + return true +} + +func TestRetrySelectionAndPermissionRecheck(t *testing.T) { + for _, tt := range []struct { + name string + visible int64 + count int64 + force bool + bound bool + want int + }{ + {"retry failed event", 1, 1, false, true, 1}, + {"retry cap", 1, 3, false, true, 0}, + {"sharing switched off after scan", 0, 0, false, true, 0}, + {"relationship changed after scan", 1, 0, false, false, 0}, + {"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 { + 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) + } + } + if !tt.force && (!strings.Contains(query, "COUNT(*)") || !strings.Contains(query, "a.created_at >= ?")) { + t.Error("missing bounded retry") + } + return nil + }, Columns: []string{"id", "couple_id", "user_id", "to_user_id", "action_type", "created_at"}, Rows: [][]driver.Value{{int64(9), int64(5), int64(1), int64(2), "period_add", "2026-10-11 12:00:00"}}}} + allowed := [][]driver.Value(nil) + if tt.bound { + allowed = [][]driver.Value{{tt.visible}} + } + steps = append(steps, testdb.Step{Match: "SELECT u.period_visible", Args: []any{int64(2), int64(1), int64(5)}, Columns: []string{"period_visible"}, Rows: allowed}) + if tt.bound && tt.visible != 0 && !tt.force { + steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM subscribe_message_logs", Args: []any{int64(9), int64(2)}, Columns: []string{"cnt"}, Rows: [][]driver.Value{{tt.count}}}) + } + db, _ := testdb.Open(t, steps) + sender := &testSender{} + Once(&store.DB{DB: db}, sender, tt.force) + if sender.calls != tt.want { + t.Fatalf("sent %d want %d", sender.calls, tt.want) + } + if sender.calls > 0 && (sender.to != 2 || sender.from != 1) { + t.Fatalf("message must go to partner: from=%d to=%d", sender.from, sender.to) + } + }) + } +} diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go index c360a76..e50b19f 100644 --- a/past-go/internal/wechat/subscribe_test.go +++ b/past-go/internal/wechat/subscribe_test.go @@ -35,13 +35,13 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { {name: "no template", openid: "recipient-openid", reason: "模板未配置"}, } { t.Run(tt.name, func(t *testing.T) { - steps := []testdb.Step{{Match: "SELECT openid FROM users", Args: []any{int64(2)}, Columns: []string{"openid"}, Rows: [][]driver.Value{{tt.openid}}}} + steps := []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}}, {Match: "SELECT openid FROM users", Args: []any{int64(2)}, Columns: []string{"openid"}, Rows: [][]driver.Value{{tt.openid}}}} if tt.openid != "" && tt.template != "" { auth := [][]driver.Value(nil) if tt.subscribed { auth = [][]driver.Value{{int64(1)}} } - steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Columns: []string{"id"}, Rows: auth}) + steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth}) } if tt.code == int64(43101) { steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"}) @@ -53,6 +53,9 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { t.Errorf("wrong recipient/event: %v", args) return nil } + if args[7] != strings.Repeat("爱", 20) { + t.Errorf("log must record normalized content: %v", args[7]) + } status := "failed" if tt.success { status = "success" @@ -89,9 +92,12 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { if payload["touser"] != tt.openid { t.Fatal("wrong WeChat recipient") } + if payload["data"].(map[string]any)["thing2"].(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 }) - success := client.SendSubscribeMessage(2, "notify", map[string]string{"thing1": "new diary", "thing2": "content"}, "", 10, 1) + success := client.SendSubscribeMessage(2, "notify", map[string]string{"thing1": "new diary", "thing2": strings.Repeat("爱", 50)}, "", 10, 1) if success != tt.success || calls != tt.requests { t.Fatalf("success=%v requests=%d", success, calls) } @@ -117,3 +123,23 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) { t.Fatal("unsubscribed send succeeded") } } + +func TestSuccessfulEventIsNotSentAgain(t *testing.T) { + db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}}) + client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db}) + client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil }) + if !client.SendSubscribeMessage(2, "notify", nil, "", 10, 1) { + t.Fatal("completed message must be treated as success") + } +} + +func TestThingNormalizationDoesNotMutateInput(t *testing.T) { + input := map[string]string{"thing1": strings.Repeat("爱💕", 30), "thing2": " ", "thing4": "行一\n行二", "date3": "2026-10-11"} + output := normalizeSubscribeData(input) + if len([]rune(output["thing1"])) != 20 || output["thing2"] == "" || output["thing4"] != "行一 行二" || output["date3"] != input["date3"] { + t.Fatalf("wrong normalized fields: %v", output) + } + if output["thing1"] == input["thing1"] || input["thing2"] != " " { + t.Fatal("input was mutated") + } +} diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index 3aec695..55e68de 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "sync" "time" @@ -25,8 +26,9 @@ type Client struct { DataDir string // 用于 access_token.cache DB *store.DB - http *http.Client - mu sync.Mutex + http *http.Client + mu sync.Mutex + sendLocks [64]sync.Mutex } func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client { @@ -186,6 +188,21 @@ func (c *Client) Jscode2Session(code string) (openid string, wxErr string, avail // SendSubscribeMessage 对应 sendSubscribeMessage()。 func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool { + // 同一事件的即时发送和补发串行,并在发送前重新检查成功记录。 + if activityID != 0 { + lock := &c.sendLocks[uint64(activityID+toUserID)%uint64(len(c.sendLocks))] + lock.Lock() + defer lock.Unlock() + previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID) + if err != nil { + slog.Error("读取通知去重状态失败", "err", err) + return false + } + if previous != nil && previous.Str("status") == "success" { + return true + } + } + data = normalizeSubscribeData(data) started := time.Now() templateID, known := c.Templates()[templateType] status, lastError, openid := "failed", "", "" @@ -286,7 +303,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data return true } lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec) - if ec == 40001 && attempt < 2 { + if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 { c.clearTokenCache() continue } @@ -298,6 +315,25 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data return false } +// 全部入口统一限制 thing 字段长度,日志保存实际下发内容。 +func normalizeSubscribeData(data map[string]string) map[string]string { + out := make(map[string]string, len(data)) + for key, value := range data { + if strings.HasPrefix(key, "thing") { + value = strings.Join(strings.Fields(value), " ") + if value == "" { + value = "有新的记录" + } + runes := []rune(value) + if len(runes) > 20 { + value = string(runes[:20]) + } + } + out[key] = value + } + return out +} + // 每次逻辑发送追加一条记录;notify_log 继续负责事件去重。 func (c *Client) logSubscribeAttempt(activityID, toUserID, userID int64, templateType, templateID, openid, page string, data map[string]string, status, errorMsg string, errorCode, httpStatus any, attempts int, started time.Time) { lookup := userID diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 97c2318..2ea8123 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -311,7 +311,7 @@ Page(withDataCache(withListPagination({ var failReasons = []; // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范) - requestSubscribeAfterAction('photo'); + await requestSubscribeAfterAction('photo'); try { // 上传前先预热 session,确保认证状态有效 diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js index 3c67d8d..e095b56 100644 --- a/weapp/pages/checkin-detail/checkin-detail.js +++ b/weapp/pages/checkin-detail/checkin-detail.js @@ -171,12 +171,12 @@ Page(withDataCache({ submitCheck: async function() { var item = this.data.item; - if (!item) return; - - // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范) - requestSubscribeAfterAction('checkin'); + if (!item || this._submittingCheck) return; + this._submittingCheck = true; try { + // 为本人接收TA的提醒补充授权。 + await requestSubscribeAfterAction('checkin'); var photoPath = ''; if (this.data.checkPhoto) { wx.showLoading({ title: '上传中...' }); @@ -206,7 +206,8 @@ Page(withDataCache({ wx.showToast({ title: toast, icon: 'success' }); // 刷新详情数据 this.loadDetail(item.id); - } catch(e) { wx.showToast({ title: e.error || '打卡失败', icon: 'none' }); } + } catch(e) { wx.hideLoading(); wx.showToast({ title: e.error || '打卡失败', icon: 'none' }); } + finally { this._submittingCheck = false; } }, hideCheckModal: function() { this.setData({ showCheckModal: false }); }, diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js index b607f2c..6e87250 100644 --- a/weapp/pages/profile/profile.js +++ b/weapp/pages/profile/profile.js @@ -886,7 +886,7 @@ Page(withDataCache({ if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; } if (!/^[0-9]{6}$/.test(inviteTargetId)) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; } // 邀请成功且对方接受时,需要通知邀请人,提前申请一次性订阅授权 - setTimeout(function() { requestSubscribe('notify').catch(function() {}); }, 1500); + requestSubscribe('notify').catch(function() {}); try { wx.showLoading({ title: '发送中...' }); const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage }); diff --git a/weapp/pages/publish/publish.js b/weapp/pages/publish/publish.js index 9050645..7012efd 100644 --- a/weapp/pages/publish/publish.js +++ b/weapp/pages/publish/publish.js @@ -353,14 +353,14 @@ Page({ } // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范) - // 订阅弹窗不阻塞后续异步操作,用户拒绝也不影响发布 + // 为本人接收TA的提醒补充授权,拒绝或保存失败也不影响发布 var sceneMap = { diary: 'diary', album: 'photo', checkin: 'checkin', timeline: 'timeline', song: 'song' }; var scene = sceneMap[type]; if (scene) { - requestSubscribeAfterAction(scene); + await requestSubscribeAfterAction(scene); } // 再执行异步操作(上传/API调用) diff --git a/weapp/pages/settings/settings.js b/weapp/pages/settings/settings.js index ec55b46..c6227c7 100644 --- a/weapp/pages/settings/settings.js +++ b/weapp/pages/settings/settings.js @@ -154,6 +154,24 @@ Page({ this._subscribing = false; }, + renewSubscribe: async function() { + if (this._subscribing) return; + this._subscribing = true; + try { + const results = await requestSubscribe('notify'); + if (results.notify) { + this.setData({ subscribeChecked: true }); + wx.showToast({ title: '已订阅一次提醒', icon: 'success' }); + } else { + wx.showToast({ title: '本次未授权提醒', icon: 'none' }); + } + } catch (e) { + wx.showToast({ title: '订阅失败,请重试', icon: 'none' }); + } finally { + this._subscribing = false; + } + }, + // ===== 背景图 ===== showBgModal: async function() { this.setData({ showBgModal: true, bgPreview: '', bgFilePath: '' }); diff --git a/weapp/pages/settings/settings.wxml b/weapp/pages/settings/settings.wxml index b58d5f6..d936145 100644 --- a/weapp/pages/settings/settings.wxml +++ b/weapp/pages/settings/settings.wxml @@ -144,20 +144,23 @@ ✅ - 通知已开启,TA的操作会第一时间通知你 + 已开启接收提醒,订阅次数用完后需要再次授权 💡 - 开启后即可收到TA的操作通知 + 开启并授权后,可接收一次TA的操作提醒 + + 📌 温馨提示 • 通知通过微信「服务通知」推送 - • 每次操作后可能会请求一次订阅授权 - • 若收不到通知,可重新开启此开关 + • 你授权的是自己接收TA提醒的次数,不能替TA授权 + • 每次授权可接收一次提醒,用完后可点击上方按钮续订 + • 勾选「总是保持以上选择」后,后续点击授权可免重复弹窗,仍需逐次申请 diff --git a/weapp/tests/subscribe.test.cjs b/weapp/tests/subscribe.test.cjs new file mode 100644 index 0000000..7b8753a --- /dev/null +++ b/weapp/tests/subscribe.test.cjs @@ -0,0 +1,53 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const path=require('node:path'); +const template='tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8'; +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)}; + const load=require('./load-module.cjs')({wx,console:{warn(){},error(){}}},{'./request':{api:{subscribeTemplate:save}}}); + return {helper:load(path.join(__dirname,'../utils/subscribe.js')),storage,calls}; +} +test('authorization is immediate and concurrent calls share one prompt until persistence finishes',async()=>{ + let complete,saves=0;const e=setup(()=>{saves++;return new Promise(resolve=>complete=resolve)}); + const first=e.helper.requestSubscribe('notify');assert.equal(e.calls.length,1); + assert.equal(e.helper.requestSubscribe('notify'),first); + e.calls[0].success({[template]:'accept'}); + let finished=false;first.then(()=>finished=true);await Promise.resolve(); + assert.equal(finished,false);assert.equal(e.storage.subscribed_notify,undefined); + complete({success:true});const result=await first;assert.equal(result.notify,true);assert.equal(saves,1);assert.equal(e.storage.subscribed_notify,true); +}); +test('failed persistence never reports successful subscription and a later tap can retry',async()=>{ + let fail=true;const e=setup(async()=>{if(fail)throw Error('offline');return {success:true}}); + let pending=e.helper.requestSubscribe('notify');e.calls[0].success({[template]:'accept'}); + await assert.rejects(pending,/offline/);assert.equal(e.storage.subscribed_notify,undefined); + 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(){}}},{ + '../../utils/layout':{getLayout:()=>({})}, + '../../utils/request':{api:{unsubscribeTemplate:()=>cancelled++}}, + '../../utils/subscribe':{requestSubscribe:()=>{calls++;return new Promise(resolve=>complete=resolve)},getSubscribeStatus:async()=>({notify:true})} + }); + loader(path.join(__dirname,'../pages/settings/settings.js')); + const instance={data:{subscribeChecked:true},setData(v){Object.assign(this.data,v)}}; + let pending=page.renewSubscribe.call(instance); + assert.equal(calls,1);await page.renewSubscribe.call(instance);assert.equal(calls,1); + complete({notify:true});await pending;assert.equal(instance.data.subscribeChecked,true);assert.equal(cancelled,0); + pending=page.renewSubscribe.call(instance);assert.equal(calls,2);complete({notify:false});await pending; + assert.equal(instance.data.subscribeChecked,true);assert.equal(instance._subscribing,false); +}); diff --git a/weapp/utils/subscribe.js b/weapp/utils/subscribe.js index cbda27d..50253d2 100644 --- a/weapp/utils/subscribe.js +++ b/weapp/utils/subscribe.js @@ -2,7 +2,7 @@ * 微信订阅消息工具 * 统一管理订阅消息的请求和状态 * - * 订阅状态以服务端接口为准 + * 服务端记录的是授权登记,不代表微信侧仍有剩余次数。 */ const { api } = require('./request'); @@ -11,8 +11,8 @@ const SUBSCRIBE_TEMPLATES = { notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8', }; -// 防重复标记:同一会话中已请求过订阅,不再重复弹窗 -let _requestedInSession = false; +// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。 +let _pendingSubscribe = null; /** * 请求用户订阅指定模板 @@ -20,7 +20,8 @@ let _requestedInSession = false; * @returns {Promise} 订阅结果 { templateType: true/false } */ function requestSubscribe(templateTypes) { - return new Promise((resolve, reject) => { + if (_pendingSubscribe) return _pendingSubscribe; + const pending = new Promise((resolve, reject) => { if (!Array.isArray(templateTypes)) { templateTypes = [templateTypes]; } @@ -48,9 +49,7 @@ function requestSubscribe(templateTypes) { }); // 将授权结果同步到后端 - syncSubscribeResults(results); - - resolve(results); + syncSubscribeResults(results).then(() => resolve(results), reject); }, fail: (err) => { console.error('[subscribe] 请求订阅失败:', err); @@ -58,62 +57,29 @@ function requestSubscribe(templateTypes) { } }); }); + _pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; }); + return _pendingSubscribe; } /** * 将订阅结果同步到后端 */ function syncSubscribeResults(results) { - Object.keys(results).forEach((type) => { - const accepted = results[type]; - const tmplId = SUBSCRIBE_TEMPLATES[type]; - if (!tmplId || tmplId === 'TEMPLATE_ID_') return; - - if (accepted) { - // 用户授权了,记录到后端,并同步本地标记 - api.subscribeTemplate({ - template_type: type, - template_id: tmplId - }).then(() => { - wx.setStorageSync('subscribed_notify', true); - }).catch((e) => { - console.warn('[subscribe] 同步订阅状态到后端失败:', e); - }); - } - // 用户拒绝时不做任何操作 - }); + return Promise.all(Object.keys(results).filter(type => results[type]).map(type => { + return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}).then(result => { + if (!result || !result.success) throw new Error('订阅授权保存失败'); + wx.setStorageSync('subscribed_notify', true); + }); + })); } -/** - * 操作后静默请求订阅(无应用层弹窗) - * - * 优化策略: - * 1. 同一会话中只弹窗一次(_requestedInSession 标记) - * 2. 用户明确关闭通知(subscribed_notify=false)时才跳过 - * 3. 如果用户没有伴侣,不弹窗 - * - * @param {string} scene - 触发场景(diary/photo/checkin/login) - */ +// 必须由用户点击直接调用;补充的是当前用户接收TA提醒的次数。 function requestSubscribeAfterAction(scene) { - // 同一会话中已请求过,跳过 - if (_requestedInSession) return; - - // 用户明确关闭了通知,不打扰 - if (wx.getStorageSync('subscribed_notify') === false) return; - - // 检查是否有伴侣,没有伴侣不需要订阅 - const coupleId = wx.getStorageSync('coupleId'); - if (!coupleId) return; - - // 标记已请求,防止重复弹窗 - _requestedInSession = true; - - // 延迟一点请求,避免干扰用户当前操作。 - // 注意:微信订阅消息是一次性授权,每次操作后都应申请续订, - // 用户勾选「总是保持以上选择」后这里会静默通过,不会反复弹窗。 - setTimeout(() => { - requestSubscribe('notify').catch(() => {}); - }, 1500); + if (wx.getStorageSync('subscribed_notify') === false || !wx.getStorageSync('coupleId')) return Promise.resolve({}); + return requestSubscribe('notify').catch(error => { + console.warn('[subscribe] 授权或保存失败,下次点击可重试:', error); + return {}; + }); } /**