Fix subscription renewal and partner notification delivery
This commit is contained in:
@@ -258,8 +258,8 @@ func subscribeTemplate(c *httpapi.Ctx) error {
|
|||||||
if !store.Truthy(templateType) || !store.Truthy(templateID) {
|
if !store.Truthy(templateType) || !store.Truthy(templateID) {
|
||||||
return c.Err(400, "缺少参数")
|
return c.Err(400, "缺少参数")
|
||||||
}
|
}
|
||||||
if _, ok := c.App.Wechat.Templates()[templateType]; !ok {
|
if configured, ok := c.App.Wechat.Templates()[templateType]; !ok || configured == "" || configured != templateID {
|
||||||
return c.Err(400, "不支持的模板类型")
|
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 {
|
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
|
return err
|
||||||
|
|||||||
@@ -63,28 +63,37 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once 执行一次扫描;force=true 时不限时间且忽略重试上限(对应 --force)
|
type Sender interface {
|
||||||
func Once(db *store.DB, wx *wechat.Client, force bool) {
|
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)), ",")
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",")
|
||||||
args := make([]any, 0, len(actionTypes)+1)
|
args := make([]any, 0, len(actionTypes)+3)
|
||||||
for _, a := range actionTypes {
|
for _, action := range actionTypes {
|
||||||
args = append(args, a)
|
args = append(args, action)
|
||||||
}
|
}
|
||||||
var sql string
|
query := `SELECT a.*, u.nickname, recipient.id AS to_user_id FROM activity_log a
|
||||||
if force {
|
JOIN users u ON u.id=a.user_id
|
||||||
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"
|
JOIN couples cp ON cp.id=a.couple_id AND cp.status='active' AND u.couple_id=cp.id
|
||||||
} else {
|
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
|
||||||
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"
|
JOIN subscribe_templates st ON st.user_id=recipient.id AND st.template_type='notify'
|
||||||
args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05"))
|
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 {
|
if err != nil {
|
||||||
slog.Error("cron_notify 查询失败", "err", err)
|
slog.Error("cron_notify 查询失败", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(activities) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
processed, success, fail, skip := 0, 0, 0, 0
|
processed, success, fail, skip := 0, 0, 0, 0
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
activityID, userID, actionType := a.Int("id"), a.Int("user_id"), a.Str("action_type")
|
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 == "" {
|
if nickname == "" {
|
||||||
nickname = "你的TA"
|
nickname = "你的TA"
|
||||||
}
|
}
|
||||||
partnerID := partnerUserID(db, userID)
|
partnerID := a.Int("to_user_id")
|
||||||
if partnerID == 0 {
|
// 扫描后共享开关或关系可能改变;实际发送前再次检查。
|
||||||
|
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++
|
skip++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, partnerID)
|
if !force {
|
||||||
retry := int64(0)
|
count, err := db.QueryOne("SELECT COUNT(*) as cnt FROM subscribe_message_logs WHERE activity_id=? AND to_user_id=?", activityID, partnerID)
|
||||||
if cnt != nil {
|
if err != nil || count == nil || count.Int("cnt") >= maxRetries {
|
||||||
retry = cnt.Int("cnt")
|
skip++
|
||||||
}
|
continue
|
||||||
if retry >= maxRetries && !force {
|
}
|
||||||
skip++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// buildNotifyData
|
// buildNotifyData
|
||||||
date := a.Str("created_at")
|
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)
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,13 +35,13 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
|||||||
{name: "no template", openid: "recipient-openid", reason: "模板未配置"},
|
{name: "no template", openid: "recipient-openid", reason: "模板未配置"},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 != "" {
|
if tt.openid != "" && tt.template != "" {
|
||||||
auth := [][]driver.Value(nil)
|
auth := [][]driver.Value(nil)
|
||||||
if tt.subscribed {
|
if tt.subscribed {
|
||||||
auth = [][]driver.Value{{int64(1)}}
|
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) {
|
if tt.code == int64(43101) {
|
||||||
steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"})
|
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)
|
t.Errorf("wrong recipient/event: %v", args)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if args[7] != strings.Repeat("爱", 20) {
|
||||||
|
t.Errorf("log must record normalized content: %v", args[7])
|
||||||
|
}
|
||||||
status := "failed"
|
status := "failed"
|
||||||
if tt.success {
|
if tt.success {
|
||||||
status = "success"
|
status = "success"
|
||||||
@@ -89,9 +92,12 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
|||||||
if payload["touser"] != tt.openid {
|
if payload["touser"] != tt.openid {
|
||||||
t.Fatal("wrong WeChat recipient")
|
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
|
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 {
|
if success != tt.success || calls != tt.requests {
|
||||||
t.Fatalf("success=%v requests=%d", success, calls)
|
t.Fatalf("success=%v requests=%d", success, calls)
|
||||||
}
|
}
|
||||||
@@ -117,3 +123,23 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
|
|||||||
t.Fatal("unsubscribed send succeeded")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ type Client struct {
|
|||||||
DataDir string // 用于 access_token.cache
|
DataDir string // 用于 access_token.cache
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
|
|
||||||
http *http.Client
|
http *http.Client
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
sendLocks [64]sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client {
|
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()。
|
// SendSubscribeMessage 对应 sendSubscribeMessage()。
|
||||||
func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool {
|
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()
|
started := time.Now()
|
||||||
templateID, known := c.Templates()[templateType]
|
templateID, known := c.Templates()[templateType]
|
||||||
status, lastError, openid := "failed", "", ""
|
status, lastError, openid := "failed", "", ""
|
||||||
@@ -286,7 +303,7 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
|
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()
|
c.clearTokenCache()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -298,6 +315,25 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data
|
|||||||
return false
|
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 继续负责事件去重。
|
// 每次逻辑发送追加一条记录;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) {
|
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
|
lookup := userID
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ Page(withDataCache(withListPagination({
|
|||||||
var failReasons = [];
|
var failReasons = [];
|
||||||
|
|
||||||
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
||||||
requestSubscribeAfterAction('photo');
|
await requestSubscribeAfterAction('photo');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 上传前先预热 session,确保认证状态有效
|
// 上传前先预热 session,确保认证状态有效
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ Page(withDataCache({
|
|||||||
|
|
||||||
submitCheck: async function() {
|
submitCheck: async function() {
|
||||||
var item = this.data.item;
|
var item = this.data.item;
|
||||||
if (!item) return;
|
if (!item || this._submittingCheck) return;
|
||||||
|
this._submittingCheck = true;
|
||||||
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
|
||||||
requestSubscribeAfterAction('checkin');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 为本人接收TA的提醒补充授权。
|
||||||
|
await requestSubscribeAfterAction('checkin');
|
||||||
var photoPath = '';
|
var photoPath = '';
|
||||||
if (this.data.checkPhoto) {
|
if (this.data.checkPhoto) {
|
||||||
wx.showLoading({ title: '上传中...' });
|
wx.showLoading({ title: '上传中...' });
|
||||||
@@ -206,7 +206,8 @@ Page(withDataCache({
|
|||||||
wx.showToast({ title: toast, icon: 'success' });
|
wx.showToast({ title: toast, icon: 'success' });
|
||||||
// 刷新详情数据
|
// 刷新详情数据
|
||||||
this.loadDetail(item.id);
|
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 }); },
|
hideCheckModal: function() { this.setData({ showCheckModal: false }); },
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ Page(withDataCache({
|
|||||||
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
|
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
|
||||||
if (!/^[0-9]{6}$/.test(inviteTargetId)) { wx.showToast({ title: 'ID为6位数字', 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 {
|
try {
|
||||||
wx.showLoading({ title: '发送中...' });
|
wx.showLoading({ title: '发送中...' });
|
||||||
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
|
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
|
||||||
|
|||||||
@@ -353,14 +353,14 @@ Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
||||||
// 订阅弹窗不阻塞后续异步操作,用户拒绝也不影响发布
|
// 为本人接收TA的提醒补充授权,拒绝或保存失败也不影响发布
|
||||||
var sceneMap = {
|
var sceneMap = {
|
||||||
diary: 'diary', album: 'photo', checkin: 'checkin',
|
diary: 'diary', album: 'photo', checkin: 'checkin',
|
||||||
timeline: 'timeline', song: 'song'
|
timeline: 'timeline', song: 'song'
|
||||||
};
|
};
|
||||||
var scene = sceneMap[type];
|
var scene = sceneMap[type];
|
||||||
if (scene) {
|
if (scene) {
|
||||||
requestSubscribeAfterAction(scene);
|
await requestSubscribeAfterAction(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再执行异步操作(上传/API调用)
|
// 再执行异步操作(上传/API调用)
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ Page({
|
|||||||
this._subscribing = false;
|
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() {
|
showBgModal: async function() {
|
||||||
this.setData({ showBgModal: true, bgPreview: '', bgFilePath: '' });
|
this.setData({ showBgModal: true, bgPreview: '', bgFilePath: '' });
|
||||||
|
|||||||
@@ -144,20 +144,23 @@
|
|||||||
<view class="subscribe-tip {{subscribeChecked ? 'on' : 'off'}}">
|
<view class="subscribe-tip {{subscribeChecked ? 'on' : 'off'}}">
|
||||||
<block wx:if="{{subscribeChecked}}">
|
<block wx:if="{{subscribeChecked}}">
|
||||||
<text class="tip-icon">✅</text>
|
<text class="tip-icon">✅</text>
|
||||||
<text class="tip-text">通知已开启,TA的操作会第一时间通知你</text>
|
<text class="tip-text">已开启接收提醒,订阅次数用完后需要再次授权</text>
|
||||||
</block>
|
</block>
|
||||||
<block wx:else>
|
<block wx:else>
|
||||||
<text class="tip-icon">💡</text>
|
<text class="tip-icon">💡</text>
|
||||||
<text class="tip-text">开启后即可收到TA的操作通知</text>
|
<text class="tip-text">开启并授权后,可接收一次TA的操作提醒</text>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<button wx:if="{{subscribeChecked}}" class="btn btn-primary" bindtap="renewSubscribe">订阅一次 TA 的提醒</button>
|
||||||
|
|
||||||
<!-- 说明 -->
|
<!-- 说明 -->
|
||||||
<view class="subscribe-note">
|
<view class="subscribe-note">
|
||||||
<text class="note-title">📌 温馨提示</text>
|
<text class="note-title">📌 温馨提示</text>
|
||||||
<text class="note-item">• 通知通过微信「服务通知」推送</text>
|
<text class="note-item">• 通知通过微信「服务通知」推送</text>
|
||||||
<text class="note-item">• 每次操作后可能会请求一次订阅授权</text>
|
<text class="note-item">• 你授权的是自己接收TA提醒的次数,不能替TA授权</text>
|
||||||
<text class="note-item">• 若收不到通知,可重新开启此开关</text>
|
<text class="note-item">• 每次授权可接收一次提醒,用完后可点击上方按钮续订</text>
|
||||||
|
<text class="note-item">• 勾选「总是保持以上选择」后,后续点击授权可免重复弹窗,仍需逐次申请</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+20
-54
@@ -2,7 +2,7 @@
|
|||||||
* 微信订阅消息工具
|
* 微信订阅消息工具
|
||||||
* 统一管理订阅消息的请求和状态
|
* 统一管理订阅消息的请求和状态
|
||||||
*
|
*
|
||||||
* 订阅状态以服务端接口为准
|
* 服务端记录的是授权登记,不代表微信侧仍有剩余次数。
|
||||||
*/
|
*/
|
||||||
const { api } = require('./request');
|
const { api } = require('./request');
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ const SUBSCRIBE_TEMPLATES = {
|
|||||||
notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
|
notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 防重复标记:同一会话中已请求过订阅,不再重复弹窗
|
// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。
|
||||||
let _requestedInSession = false;
|
let _pendingSubscribe = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求用户订阅指定模板
|
* 请求用户订阅指定模板
|
||||||
@@ -20,7 +20,8 @@ let _requestedInSession = false;
|
|||||||
* @returns {Promise<object>} 订阅结果 { templateType: true/false }
|
* @returns {Promise<object>} 订阅结果 { templateType: true/false }
|
||||||
*/
|
*/
|
||||||
function requestSubscribe(templateTypes) {
|
function requestSubscribe(templateTypes) {
|
||||||
return new Promise((resolve, reject) => {
|
if (_pendingSubscribe) return _pendingSubscribe;
|
||||||
|
const pending = new Promise((resolve, reject) => {
|
||||||
if (!Array.isArray(templateTypes)) {
|
if (!Array.isArray(templateTypes)) {
|
||||||
templateTypes = [templateTypes];
|
templateTypes = [templateTypes];
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,7 @@ function requestSubscribe(templateTypes) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 将授权结果同步到后端
|
// 将授权结果同步到后端
|
||||||
syncSubscribeResults(results);
|
syncSubscribeResults(results).then(() => resolve(results), reject);
|
||||||
|
|
||||||
resolve(results);
|
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error('[subscribe] 请求订阅失败:', err);
|
console.error('[subscribe] 请求订阅失败:', err);
|
||||||
@@ -58,62 +57,29 @@ function requestSubscribe(templateTypes) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; });
|
||||||
|
return _pendingSubscribe;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将订阅结果同步到后端
|
* 将订阅结果同步到后端
|
||||||
*/
|
*/
|
||||||
function syncSubscribeResults(results) {
|
function syncSubscribeResults(results) {
|
||||||
Object.keys(results).forEach((type) => {
|
return Promise.all(Object.keys(results).filter(type => results[type]).map(type => {
|
||||||
const accepted = results[type];
|
return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}).then(result => {
|
||||||
const tmplId = SUBSCRIBE_TEMPLATES[type];
|
if (!result || !result.success) throw new Error('订阅授权保存失败');
|
||||||
if (!tmplId || tmplId === 'TEMPLATE_ID_') return;
|
wx.setStorageSync('subscribed_notify', true);
|
||||||
|
});
|
||||||
if (accepted) {
|
}));
|
||||||
// 用户授权了,记录到后端,并同步本地标记
|
|
||||||
api.subscribeTemplate({
|
|
||||||
template_type: type,
|
|
||||||
template_id: tmplId
|
|
||||||
}).then(() => {
|
|
||||||
wx.setStorageSync('subscribed_notify', true);
|
|
||||||
}).catch((e) => {
|
|
||||||
console.warn('[subscribe] 同步订阅状态到后端失败:', e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 用户拒绝时不做任何操作
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 必须由用户点击直接调用;补充的是当前用户接收TA提醒的次数。
|
||||||
* 操作后静默请求订阅(无应用层弹窗)
|
|
||||||
*
|
|
||||||
* 优化策略:
|
|
||||||
* 1. 同一会话中只弹窗一次(_requestedInSession 标记)
|
|
||||||
* 2. 用户明确关闭通知(subscribed_notify=false)时才跳过
|
|
||||||
* 3. 如果用户没有伴侣,不弹窗
|
|
||||||
*
|
|
||||||
* @param {string} scene - 触发场景(diary/photo/checkin/login)
|
|
||||||
*/
|
|
||||||
function requestSubscribeAfterAction(scene) {
|
function requestSubscribeAfterAction(scene) {
|
||||||
// 同一会话中已请求过,跳过
|
if (wx.getStorageSync('subscribed_notify') === false || !wx.getStorageSync('coupleId')) return Promise.resolve({});
|
||||||
if (_requestedInSession) return;
|
return requestSubscribe('notify').catch(error => {
|
||||||
|
console.warn('[subscribe] 授权或保存失败,下次点击可重试:', error);
|
||||||
// 用户明确关闭了通知,不打扰
|
return {};
|
||||||
if (wx.getStorageSync('subscribed_notify') === false) return;
|
});
|
||||||
|
|
||||||
// 检查是否有伴侣,没有伴侣不需要订阅
|
|
||||||
const coupleId = wx.getStorageSync('coupleId');
|
|
||||||
if (!coupleId) return;
|
|
||||||
|
|
||||||
// 标记已请求,防止重复弹窗
|
|
||||||
_requestedInSession = true;
|
|
||||||
|
|
||||||
// 延迟一点请求,避免干扰用户当前操作。
|
|
||||||
// 注意:微信订阅消息是一次性授权,每次操作后都应申请续订,
|
|
||||||
// 用户勾选「总是保持以上选择」后这里会静默通过,不会反复弹窗。
|
|
||||||
setTimeout(() => {
|
|
||||||
requestSubscribe('notify').catch(() => {});
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user