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