diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go index 89e7e2e..d6d4861 100644 --- a/past-go/internal/handler/admin.go +++ b/past-go/internal/handler/admin.go @@ -36,6 +36,7 @@ func registerAdmin(r *httpapi.Router) { r.Handle("admin_contents", adminContents) r.Handle("admin_reset_user_password", adminResetUserPassword) r.Handle("admin_activity_log", adminActivityLog) + r.Handle("admin_subscribe_logs", adminSubscribeLogs) r.Handle("admin_get_global_bg", adminGetGlobalBg) r.Handle("admin_set_global_bg", adminSetGlobalBg) r.Handle("admin_clear_global_bg", adminClearGlobalBg) diff --git a/past-go/internal/handler/admin_subscribe.go b/past-go/internal/handler/admin_subscribe.go new file mode 100644 index 0000000..b56c689 --- /dev/null +++ b/past-go/internal/handler/admin_subscribe.go @@ -0,0 +1,64 @@ +package handler + +import ( + "math" + "strings" + "time" + + "past/internal/httpapi" +) + +func adminSubscribeLogs(c *httpapi.Ctx) error { + where := []string{"1=1"} + args := []any{} + status := c.Q("status") + if status != "" { + if status != "success" && status != "failed" { + return c.Err(400, "发送状态无效") + } + where = append(where, "l.status = ?") + args = append(args, status) + } + if action := strings.TrimSpace(c.Q("action_type")); action != "" { + where = append(where, "l.action_type = ?") + args = append(args, action) + } + if keyword := strings.TrimSpace(c.Q("keyword")); keyword != "" { + like := "%" + keyword + "%" + where = append(where, "(l.to_openid LIKE ? OR recipient.nickname LIKE ? OR recipient.user_id LIKE ?)") + args = append(args, like, like, like) + } + var from, to time.Time + for _, key := range []string{"start_date", "end_date"} { + if value := c.Q(key); value != "" { + date, err := time.Parse("2006-01-02", value) + if err != nil { + return c.Err(400, "日期格式错误,请使用 YYYY-MM-DD") + } + if key == "start_date" { + from = date + where = append(where, "l.created_at >= ?") + args = append(args, date.Format("2006-01-02")) + } else { + to = date + where = append(where, "l.created_at < ?") + args = append(args, date.AddDate(0, 0, 1).Format("2006-01-02")) + } + } + } + if !from.IsZero() && !to.IsZero() && from.After(to) { + return c.Err(400, "开始日期不能晚于结束日期") + } + base := " FROM subscribe_message_logs l LEFT JOIN users recipient ON recipient.id=l.to_user_id LEFT JOIN users sender ON sender.id=l.user_id WHERE " + strings.Join(where, " AND ") + total, err := c.DB.QueryOne("SELECT COUNT(*) AS total"+base, args...) + if err != nil { + return err + } + page, size, offset := pageParams(c, 10, 100) + items, err := c.DB.Query("SELECT l.*, recipient.nickname AS recipient_name, recipient.user_id AS recipient_uid, sender.nickname AS sender_name"+base+" ORDER BY l.id DESC LIMIT ? OFFSET ?", append(args, size, offset)...) + if err != nil { + return err + } + count := total.Int("total") + return c.OK(M{"items": rows(items), "total": count, "page": page, "page_size": size, "total_pages": int64(math.Ceil(float64(count) / float64(size)))}) +} diff --git a/past-go/internal/handler/admin_subscribe_test.go b/past-go/internal/handler/admin_subscribe_test.go new file mode 100644 index 0000000..28c7056 --- /dev/null +++ b/past-go/internal/handler/admin_subscribe_test.go @@ -0,0 +1,38 @@ +package handler + +import ( + "database/sql/driver" + "past/internal/testdb" + "strings" + "testing" +) + +func TestAdminSubscribeLogsFilteredServerPagination(t *testing.T) { + filters := []any{"failed", "diary_create", "%recipient%", "%recipient%", "%recipient%", "2026-10-01", "2026-10-12"} + predicate := "l.status = ? AND l.action_type = ? AND (l.to_openid LIKE ? OR recipient.nickname LIKE ? OR recipient.user_id LIKE ?) AND l.created_at >= ? AND l.created_at < ?" + c, out, _ := permissionCtx(t, "GET", "/api/admin_subscribe_logs?status=failed&action_type=diary_create&keyword=recipient&start_date=2026-10-01&end_date=2026-10-11&page=2&page_size=10", "", []testdb.Step{ + {Match: "SELECT COUNT(*) AS total", Args: filters, Check: func(q string, _ []any) error { + if !strings.Contains(q, predicate) { + t.Errorf("wrong filters %s", q) + } + return nil + }, Columns: []string{"total"}, Rows: [][]driver.Value{{int64(21)}}}, + {Match: "ORDER BY l.id DESC LIMIT ? OFFSET ?", Args: append(filters, int64(10), int64(10)), Columns: []string{"id", "to_openid", "status", "error_msg"}, Rows: [][]driver.Value{{int64(11), "recipient-openid", "failed", "用户未订阅"}}}, + }) + adminSubscribeLogs(c) + for _, want := range []string{`"total":21`, `"total_pages":3`, `"page":2`, `recipient-openid`, `用户未订阅`} { + if !strings.Contains(out.Body.String(), want) { + t.Fatalf("missing %s: %s", want, out.Body.String()) + } + } +} + +func TestAdminSubscribeLogsRejectsInvalidFilters(t *testing.T) { + for _, query := range []string{"status=invalid", "start_date=2026-02-30", "start_date=2026-10-12&end_date=2026-10-01"} { + c, out, _ := permissionCtx(t, "GET", "/api/admin_subscribe_logs?"+query, "", nil) + adminSubscribeLogs(c) + if out.Code != 400 { + t.Fatalf("invalid filters accepted: %s", query) + } + } +} diff --git a/past-go/internal/handler/pet.go b/past-go/internal/handler/pet.go index 04922bc..0105fdd 100644 --- a/past-go/internal/handler/pet.go +++ b/past-go/internal/handler/pet.go @@ -245,14 +245,14 @@ func petAdopt(c *httpapi.Ctx) error { return err } c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'adopt', ?)", coupleID, uid, "领养了"+name+"!🎉") - logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "") + actID := logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "") c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID) checkPetAchievements(c, coupleID, 1, 1, 100, false) if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { - // PHP 中 $actId 未定义(null),不写 notify_log + // 使用活动ID记录发送事件,供发送日志和去重查询使用。 c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕", - }, "pages/pet/pet", 0, uid) + }, "pages/pet/pet", actID, uid) } return c.OK(M{"success": true, "pet_id": fmt.Sprint(petID)}) } @@ -370,7 +370,7 @@ func petDailyAction(c *httpapi.Ctx) error { logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "") } checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false) - logActivity(c.DB, coupleID, uid, "pet_daily", msg, "") + actID := logActivity(c.DB, coupleID, uid, "pet_daily", msg, "") if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { notifyMsg := msg if allDone { @@ -378,7 +378,7 @@ func petDailyAction(c *httpapi.Ctx) error { } c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "宠物日常 🐾", "thing2": notifyMsg, "date3": td, "thing4": "点击查看宠物详情 💕", - }, "pages/pet/pet", 0, uid) + }, "pages/pet/pet", actID, uid) } return c.OK(M{ "success": true, "exp_gain": effects["exp"], "happiness": newHappiness, "hunger": newHunger, "energy": newEnergy, @@ -460,7 +460,7 @@ func petRename(c *httpapi.Ctx) error { return err } c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'rename', ?)", coupleID, uid, "给宠物改名为"+name+" ✏️") - logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "") + actID := logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "") info, _ := c.DB.QueryOne("SELECT level, stage, happiness FROM pets WHERE couple_id = ?", coupleID) var level, stage, happiness int64 if info != nil { @@ -470,7 +470,7 @@ func petRename(c *httpapi.Ctx) error { if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕", - }, "pages/pet/pet", 0, uid) + }, "pages/pet/pet", actID, uid) } return c.OK(M{"success": true, "name": name}) } diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go index a31c111..70ab754 100644 --- a/past-go/internal/httpapi/router.go +++ b/past-go/internal/httpapi/router.go @@ -54,7 +54,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") -var adminActions = set("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") +var adminActions = set("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") func set(items ...string) map[string]bool { m := make(map[string]bool, len(items)) diff --git a/past-go/internal/httpapi/router_test.go b/past-go/internal/httpapi/router_test.go index 8dfa5fb..c2d43b3 100644 --- a/past-go/internal/httpapi/router_test.go +++ b/past-go/internal/httpapi/router_test.go @@ -42,7 +42,7 @@ func TestCleanAPIRoutes(t *testing.T) { } }) } - for _, uri := range []string{"/api/couple_info", "/api/period_visible_set", "/api/upload", "/api/admin_stats"} { + for _, uri := range []string{"/api/couple_info", "/api/period_visible_set", "/api/upload", "/api/admin_stats", "/api/adminSubscribeLogs"} { out := httptest.NewRecorder() engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, uri, strings.NewReader("{}"))) if out.Code != 401 { diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go index eedb9c6..cca08b5 100644 --- a/past-go/internal/notify/notify.go +++ b/past-go/internal/notify/notify.go @@ -65,7 +65,6 @@ 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) { - now := time.Now().Format("2006-01-02 15:04:05") ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",") args := make([]any, 0, len(actionTypes)+1) for _, a := range actionTypes { @@ -88,7 +87,7 @@ func Once(db *store.DB, wx *wechat.Client, force bool) { } processed, success, fail, skip := 0, 0, 0, 0 for _, a := range activities { - activityID, coupleID, userID, actionType := a.Int("id"), a.Int("couple_id"), a.Int("user_id"), a.Str("action_type") + activityID, userID, actionType := a.Int("id"), a.Int("user_id"), a.Str("action_type") nickname := a.Str("nickname") if nickname == "" { nickname = "你的TA" @@ -129,16 +128,6 @@ func Once(db *store.DB, wx *wechat.Client, force bool) { data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"} ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID) - status, errMsg := "success", "" - if !ok { - status, errMsg = "failed", "发送失败(可能用户未订阅或微信接口返回错误)" - } - if retry == 0 { - db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - activityID, coupleID, userID, actionType, "notify", partnerID, status, errMsg, now) - } else { - db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errMsg, now, activityID, partnerID) - } processed++ if ok { success++ diff --git a/past-go/internal/store/schema_sql.go b/past-go/internal/store/schema_sql.go index 7d8382b..222aac1 100644 --- a/past-go/internal/store/schema_sql.go +++ b/past-go/internal/store/schema_sql.go @@ -314,6 +314,34 @@ var createTables = []string{ INDEX idx_token (token), INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS subscribe_message_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + activity_id INT NOT NULL DEFAULT 0, + couple_id INT DEFAULT NULL, + user_id INT NOT NULL DEFAULT 0, + to_user_id INT NOT NULL, + to_openid VARCHAR(128) NOT NULL DEFAULT '', + action_type VARCHAR(50) NOT NULL DEFAULT '', + message_title VARCHAR(255) NOT NULL DEFAULT '', + message_content TEXT, + template_type VARCHAR(50) NOT NULL, + template_id VARCHAR(128) NOT NULL DEFAULT '', + page VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL, + error_code INT DEFAULT NULL, + http_status INT DEFAULT NULL, + error_msg TEXT, + attempts INT NOT NULL DEFAULT 0, + duration_ms INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at DATETIME DEFAULT NULL, + INDEX idx_status_time (status, created_at), + INDEX idx_recipient_time (to_user_id, created_at), + INDEX idx_openid (to_openid), + INDEX idx_event_time (action_type, created_at), + INDEX idx_activity (activity_id), + INDEX idx_couple (couple_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS notify_log ( id INT AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL, diff --git a/past-go/internal/store/unbinding_cleanup.go b/past-go/internal/store/unbinding_cleanup.go index 5dd4375..2a7d20d 100644 --- a/past-go/internal/store/unbinding_cleanup.go +++ b/past-go/internal/store/unbinding_cleanup.go @@ -30,6 +30,7 @@ var sharedDataDeleteStatements = []string{ "DELETE FROM pets WHERE couple_id = ?", "DELETE FROM activity_log WHERE couple_id = ?", "DELETE FROM notify_log WHERE couple_id = ?", + "DELETE FROM subscribe_message_logs WHERE couple_id = ?", "DELETE FROM settings WHERE `key` IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))", } diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go new file mode 100644 index 0000000..c360a76 --- /dev/null +++ b/past-go/internal/wechat/subscribe_test.go @@ -0,0 +1,119 @@ +package wechat + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "past/internal/store" + "past/internal/testdb" + "path/filepath" + "strings" + "testing" +) + +type subscribeTransport func(*http.Request) (*http.Response, error) + +func (f subscribeTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestSubscribeRecordsEveryOutcome(t *testing.T) { + for _, tt := range []struct { + name, openid, template, response, reason string + subscribed, success bool + code any + requests int + }{ + {name: "success", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":0}`, success: true, code: int64(0), requests: 1}, + {name: "quota exhausted", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":43101,"errmsg":"user refuse"}`, reason: "43101", code: int64(43101), requests: 1}, + {name: "malformed response", openid: "recipient-openid", template: "template-id", subscribed: true, response: `not-json`, reason: "无效", requests: 1}, + {name: "invalid errcode", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{"errcode":"invalid"}`, reason: "无效", requests: 1}, + {name: "missing errcode", openid: "recipient-openid", template: "template-id", subscribed: true, response: `{}`, reason: "errcode", requests: 1}, + {name: "no authorization", openid: "recipient-openid", template: "template-id", reason: "未订阅"}, + {name: "no openid", template: "template-id", reason: "openid"}, + {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}}}} + 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}) + } + if tt.code == int64(43101) { + steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"}) + } + 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 { + if args[4] != tt.openid || args[5] != "diary_create" || args[6] != "new diary" { + t.Errorf("wrong recipient/event: %v", args) + return nil + } + status := "failed" + if tt.success { + status = "success" + } + if args[11] != status || args[12] != tt.code || args[15] != int64(tt.requests) { + t.Errorf("wrong result: %v", args) + return nil + } + if !strings.Contains(fmt.Sprint(args[14]), tt.reason) { + t.Errorf("missing failure reason: %v", args[14]) + return nil + } + if tt.success && args[17] == nil { + t.Error("missing sent_at") + return nil + } + if !tt.success && args[17] != nil { + t.Error("failed message has sent_at") + return nil + } + return nil + }}) + steps = append(steps, meta...) + steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"}) + db, _ := testdb.Open(t, steps) + client := New("app", "secret", tt.template, t.TempDir(), &store.DB{DB: db}) + os.MkdirAll(filepath.Dir(client.cacheFile()), 0755) + os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600) + calls := 0 + client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) { + calls++ + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["touser"] != tt.openid { + t.Fatal("wrong WeChat recipient") + } + 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) + if success != tt.success || calls != tt.requests { + t.Fatalf("success=%v requests=%d", success, calls) + } + }) + } +} + +func TestSubscribeWithoutActivityStillRecords(t *testing.T) { + db, _ := testdb.Open(t, []testdb.Step{ + {Match: "SELECT openid FROM users", Columns: []string{"openid"}, Rows: [][]driver.Value{{"recipient"}}}, + {Match: "SELECT id FROM subscribe_templates", Columns: []string{"id"}}, + {Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{nil}}}, + {Match: "INSERT INTO subscribe_message_logs", Check: func(_ string, args []any) error { + if args[0] != int64(0) || args[4] != "recipient" || args[11] != "failed" { + t.Error("missing zero-activity log") + return nil + } + return nil + }}, + }) + client := New("app", "secret", "template", t.TempDir(), &store.DB{DB: db}) + if client.SendSubscribeMessage(2, "notify", nil, "", 0, 0) { + t.Fatal("unsubscribed send succeeded") + } +} diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index cbb537b..3aec695 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -186,41 +186,57 @@ 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 { - templateID, ok := c.Templates()[templateType] - if !ok { + started := time.Now() + templateID, known := c.Templates()[templateType] + status, lastError, openid := "failed", "", "" + attempts := 0 + var errorCode, httpStatus any + if page == "" { + page = "pages/index/index" + } + defer func() { + c.logSubscribeAttempt(activityID, toUserID, userID, templateType, templateID, openid, page, data, status, lastError, errorCode, httpStatus, attempts, started) if activityID != 0 { - c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "模板类型不存在") + c.LogNotifyResult(activityID, toUserID, userID, templateType, status, lastError) } + }() + user, err := c.DB.QueryOne("SELECT openid FROM users WHERE id = ?", toUserID) + if err != nil { + lastError = "读取收件人失败" + return false + } + if user != nil { + openid = user.Str("openid") + } + if !known || templateID == "" { + lastError = "订阅消息模板未配置" + return false + } + if openid == "" { + lastError = "用户无openid" + return false + } + sub, err := c.DB.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) + if err != nil { + lastError = "读取订阅授权失败" return false } - db := c.DB - sub, _ := db.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) if sub == nil { - if activityID != 0 { - c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户未订阅") - } - return false - } - user, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", toUserID) - if user == nil || user.Str("openid") == "" { - if activityID != 0 { - c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户无openid") - } + lastError = "用户未订阅" return false } msgData := map[string]any{} for k, v := range data { msgData[k] = map[string]any{"value": v} } - if page == "" { - page = "pages/index/index" - } - post, _ := marshalUnescaped(map[string]any{ - "touser": user.Str("openid"), "template_id": templateID, "page": page, + post, err := marshalUnescaped(map[string]any{ + "touser": openid, "template_id": templateID, "page": page, "data": msgData, "miniprogram_state": "formal", "lang": "zh_CN", }) - - lastError := "" + if err != nil { + lastError = "消息内容编码失败" + return false + } for attempt := 1; attempt <= 2; attempt++ { tok := c.AccessToken() if tok == "" { @@ -230,15 +246,25 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data } continue } - cl := &http.Client{Timeout: 5 * time.Second} + attempts++ + cl := &http.Client{Timeout: 5 * time.Second, Transport: c.http.Transport} resp, err := cl.Post("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="+tok, "application/json", bytes.NewReader(post)) - if err != nil || resp.StatusCode != 200 { - code := 0 - if resp != nil { - code = resp.StatusCode - resp.Body.Close() + if err != nil { + // 不记录含 access_token 的 URL;底层错误足以定位网络故障。 + lastError = "微信发送请求失败" + if uerr, ok := err.(*url.Error); ok { + lastError += ": " + uerr.Err.Error() } - lastError = fmt.Sprintf("HTTP %d", code) + if attempt < 2 { + c.clearTokenCache() + time.Sleep(200 * time.Millisecond) + } + continue + } + httpStatus = resp.StatusCode + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode) if attempt < 2 { c.clearTokenCache() time.Sleep(200 * time.Millisecond) @@ -246,13 +272,17 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data continue } var result map[string]any - json.NewDecoder(resp.Body).Decode(&result) + decodeErr := json.NewDecoder(resp.Body).Decode(&result) resp.Body.Close() + _, validCode := result["errcode"].(float64) + if decodeErr != nil || !validCode { + lastError = "微信返回内容无效或缺少errcode" + break + } ec := errcode(result) + errorCode = ec if ec == 0 { - if activityID != 0 { - c.LogNotifyResult(activityID, toUserID, userID, templateType, "success", "") - } + status, lastError = "success", "" return true } lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec) @@ -260,20 +290,44 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data c.clearTokenCache() continue } - // 43101 表示用户拒收或一次性订阅额度已用尽,40003 表示 openid 无效; - // 这两种情况下订阅记录已失效,删除记录让前端设置页显示「未订阅」, - // 引导用户重新授权,避免出现「显示已订阅却收不到」的假象。 if ec == 40003 || ec == 43101 { - db.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) + c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) } break } - if activityID != 0 { - c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", lastError) - } return false } +// 每次逻辑发送追加一条记录;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 + if lookup == 0 { + lookup = toUserID + } + var coupleID any + if user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup); user != nil { + coupleID = user["couple_id"] + } + action := "" + if activityID != 0 { + if activity, _ := c.DB.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID); activity != nil { + action = activity.Str("action_type") + } + } + if action == "" { + action = "notify" + } + var sentAt any + if status == "success" { + sentAt = time.Now().Format("2006-01-02 15:04:05") + } + _, 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) + if err != nil { + slog.Error("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err) + } +} + // LogNotifyResult 对应 logNotifyResult() func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateType, status, errorMsg string) { db := c.DB diff --git a/past-go/web/admin/src/App.tsx b/past-go/web/admin/src/App.tsx index 728151b..d38f38f 100644 --- a/past-go/web/admin/src/App.tsx +++ b/past-go/web/admin/src/App.tsx @@ -10,6 +10,7 @@ import ContentsPage from "@/pages/Contents" import BackgroundsPage from "@/pages/Backgrounds" import UnbindPage from "@/pages/Unbind" import PetsPage from "@/pages/Pets" +import SubscribeMessagesPage from "@/pages/SubscribeMessages" import SettingsPage from "@/pages/Settings" // 登录态:admin_check_auth(Cookie 会话) @@ -44,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> } /> ) : ( diff --git a/past-go/web/admin/src/components/Layout.tsx b/past-go/web/admin/src/components/Layout.tsx index c24ccf8..e956f3c 100644 --- a/past-go/web/admin/src/components/Layout.tsx +++ b/past-go/web/admin/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom" import { useQueryClient, useIsFetching } from "@tanstack/react-query" -import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight } from "lucide-react" +import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell } from "lucide-react" import { toast } from "sonner" import { api } from "@/lib/api" import { cn } from "@/lib/utils" @@ -17,6 +17,7 @@ const NAV = [ { to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" }, { to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" }, { to: "/settings", label: "系统设置", icon: Settings, group: "系统" }, + { to: "/subscribe-messages", label: "订阅消息", icon: Bell, group: "系统" }, ] function NavItems({ onNavigate }: { onNavigate?: () => void }) { return {["工作台", "空间管理", "系统"].map(group => {group}{NAV.filter(item => item.group === group).map(({to,label,icon: Icon,end}) => cn("admin-nav-link",isActive && "active")}>{label})})} diff --git a/past-go/web/admin/src/pages/SubscribeMessages.tsx b/past-go/web/admin/src/pages/SubscribeMessages.tsx new file mode 100644 index 0000000..7d800a2 --- /dev/null +++ b/past-go/web/admin/src/pages/SubscribeMessages.tsx @@ -0,0 +1,57 @@ +import { useState, type FormEvent } from "react" +import { useQuery } from "@tanstack/react-query" +import { RefreshCw, Search } from "lucide-react" +import { api, fmtTime } from "@/lib/api" +import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination" +import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common" +import { Card, CardContent } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +const EVENTS: Record = { + login:"登录", couple_bind:"情侣绑定", timeline_create:"新增记忆日", album_create:"创建相册", photo_upload:"上传照片", + diary_create:"发布日记", diary_comment_add:"日记评论", song_add:"收藏歌曲", checkin_do:"打卡", + smoking_consume:"抽烟打卡", smoking_give:"递烟", smoking_receive_smoke:"收到并抽烟", period_add:"记录经期", period_sex_add:"记录亲密生活", + pet_adopt:"领养宠物", pet_daily:"宠物日常", pet_rename:"宠物改名", notify:"其他消息" +} +type Log = {id:string;activity_id:string;to_openid:string;to_user_id:string;recipient_name?:string;recipient_uid?:string;sender_name?:string;user_id:string;action_type:string;message_title:string;message_content:string;template_type:string;template_id:string;page:string;status:string;error_code?:number|null;http_status?:number|null;error_msg?:string;attempts:number;duration_ms:number;created_at:string;sent_at?:string} +const initialFilters={keyword:"",status:"all",action_type:"all",start_date:"",end_date:""} + +export default function SubscribeMessagesPage(){ + const [draft,setDraft]=useState(initialFilters),[filters,setFilters]=useState(initialFilters),[page,setPage]=useState(1) + const q=useQuery({queryKey:["admin_subscribe_logs",filters,page],queryFn:()=>api>("adminSubscribeLogs",{...filters,status:filters.status==="all"?undefined:filters.status,action_type:filters.action_type==="all"?undefined:filters.action_type,page,page_size:PAGE_SIZE},"GET")}) + function search(e:FormEvent){e.preventDefault();setPage(1);setFilters({...draft})} + return + 订阅消息 + 查看每次发送的收件人、事件和结果。未订阅、缺少 OpenID 等未发出的消息也会记录失败原因。 + + + 收件人setDraft({...draft,keyword:e.target.value})}/> + 发送结果setDraft({...draft,status:v})}>全部结果发送成功发送失败 + 事件setDraft({...draft,action_type:v})}>全部事件{Object.entries(EVENTS).map(([key,label])=>{label})} + 开始日期setDraft({...draft,start_date:e.target.value})}/> + 结束日期setDraft({...draft,end_date:e.target.value})}/> + 查询 + {setDraft(initialFilters);setFilters(initialFilters);setPage(1)}}>重置 + q.refetch()}>刷新 + + {q.isPending?:q.isError?:!q.data?.items.length?: + 记录时间收件人 / OpenID发起人事件 / 消息发送结果失败原因尝试次数 / 耗时发送详情 + {q.data.items.map(r=> + {fmtTime(r.created_at)}#{r.id} + {r.recipient_name||`用户#${r.to_user_id}`}{r.recipient_uid&&ID {r.recipient_uid}}{r.to_openid||"无 OpenID"} + {r.sender_name|| (Number(r.user_id)?`用户#${r.user_id}`:"系统")} + {EVENTS[r.action_type]||r.action_type}{r.message_title}{r.message_content} + {r.status==="success"?"发送成功":"发送失败"}{r.sent_at&&{fmtTime(r.sent_at)}} + {r.error_msg||"—"}{r.error_code!=null&&微信错误码:{r.error_code}}{r.http_status!=null&&HTTP:{r.http_status}} + {r.attempts} 次{r.duration_ms} ms + 查看详情活动ID:{r.activity_id}模板:{r.template_type}模板ID:{r.template_id||"未配置"}跳转页面:{r.page} + )} + } + + +}
查看每次发送的收件人、事件和结果。未订阅、缺少 OpenID 等未发出的消息也会记录失败原因。