Record subscription message delivery and add admin log page

This commit is contained in:
2026-10-11 10:21:15 +08:00
parent 7854d94e7b
commit c5cab1b945
14 changed files with 416 additions and 62 deletions
+1
View File
@@ -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)
@@ -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)))})
}
@@ -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)
}
}
}
+7 -7
View File
@@ -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})
}
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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 {
+1 -12
View File
@@ -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++
+28
View File
@@ -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,
@@ -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_', ?))",
}
+119
View File
@@ -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")
}
}
+96 -42
View File
@@ -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 {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "模板类型不存在")
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, 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()
ec := errcode(result)
if ec == 0 {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "success", "")
_, validCode := result["errcode"].(float64)
if decodeErr != nil || !validCode {
lastError = "微信返回内容无效或缺少errcode"
break
}
ec := errcode(result)
errorCode = ec
if ec == 0 {
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
+2
View File
@@ -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() {
<Route path="unbind" element={<UnbindPage />} />
<Route path="pets" element={<PetsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="subscribe-messages" element={<SubscribeMessagesPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
) : (
+2 -1
View File
@@ -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 <nav className="admin-navigation">{["工作台", "空间管理", "系统"].map(group => <section key={group}><div className="nav-group-label">{group}</div>{NAV.filter(item => item.group === group).map(({to,label,icon: Icon,end}) => <NavLink key={to} to={to} end={end} onClick={onNavigate} className={({isActive}) => cn("admin-nav-link",isActive && "active")}><Icon size={18}/><span>{label}</span></NavLink>)}</section>)}</nav>
@@ -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<string,string> = {
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<Paged<Log>>("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 <div>
<PageTitle count={q.data?`共 ${q.data.total} 条记录`:undefined}>订阅消息</PageTitle>
<p className="mb-4 text-sm text-muted-foreground">查看每次发送的收件人、事件和结果。未订阅、缺少 OpenID 等未发出的消息也会记录失败原因。</p>
<Card><CardContent className="space-y-4">
<form onSubmit={search} className="flex flex-wrap items-end gap-3">
<div className="space-y-1"><Label htmlFor="subscribe-keyword">收件人</Label><Input id="subscribe-keyword" className="w-64" placeholder="OpenID、昵称或用户ID" value={draft.keyword} onChange={e=>setDraft({...draft,keyword:e.target.value})}/></div>
<div className="space-y-1"><Label>发送结果</Label><Select value={draft.status} onValueChange={v=>setDraft({...draft,status:v})}><SelectTrigger className="w-36"><SelectValue/></SelectTrigger><SelectContent><SelectItem value="all">全部结果</SelectItem><SelectItem value="success">发送成功</SelectItem><SelectItem value="failed">发送失败</SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label>事件</Label><Select value={draft.action_type} onValueChange={v=>setDraft({...draft,action_type:v})}><SelectTrigger className="w-40"><SelectValue/></SelectTrigger><SelectContent><SelectItem value="all">全部事件</SelectItem>{Object.entries(EVENTS).map(([key,label])=><SelectItem key={key} value={key}>{label}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label htmlFor="subscribe-from">开始日期</Label><Input id="subscribe-from" type="date" value={draft.start_date} onChange={e=>setDraft({...draft,start_date:e.target.value})}/></div>
<div className="space-y-1"><Label htmlFor="subscribe-to">结束日期</Label><Input id="subscribe-to" type="date" value={draft.end_date} onChange={e=>setDraft({...draft,end_date:e.target.value})}/></div>
<Button type="submit"><Search className="size-4"/>查询</Button>
<Button type="button" variant="outline" onClick={()=>{setDraft(initialFilters);setFilters(initialFilters);setPage(1)}}>重置</Button>
<Button type="button" variant="outline" disabled={q.isFetching} onClick={()=>q.refetch()}><RefreshCw className="size-4"/>刷新</Button>
</form>
{q.isPending?<Loading rows={5}/>:q.isError?<ErrorBox error={q.error}/>:!q.data?.items.length?<Empty text="暂无订阅消息发送记录"/>:<div className="overflow-x-auto"><Table>
<TableHeader><TableRow><TableHead>记录时间</TableHead><TableHead>收件人 / OpenID</TableHead><TableHead>发起人</TableHead><TableHead>事件 / 消息</TableHead><TableHead>发送结果</TableHead><TableHead>失败原因</TableHead><TableHead>尝试次数 / 耗时</TableHead><TableHead>发送详情</TableHead></TableRow></TableHeader>
<TableBody>{q.data.items.map(r=><TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-xs">{fmtTime(r.created_at)}<div className="text-muted-foreground">#{r.id}</div></TableCell>
<TableCell><div>{r.recipient_name||`用户#${r.to_user_id}`}{r.recipient_uid&&<span className="ml-2 text-xs text-muted-foreground">ID {r.recipient_uid}</span>}</div><div className="mt-1 max-w-64 break-all font-mono text-xs text-muted-foreground select-text">{r.to_openid||"无 OpenID"}</div></TableCell>
<TableCell className="whitespace-nowrap">{r.sender_name|| (Number(r.user_id)?`用户#${r.user_id}`:"系统")}</TableCell>
<TableCell className="min-w-56 max-w-80"><Badge variant="outline">{EVENTS[r.action_type]||r.action_type}</Badge><div className="mt-1">{r.message_title}</div><div className="text-xs text-muted-foreground break-words">{r.message_content}</div></TableCell>
<TableCell className="whitespace-nowrap"><Badge variant={r.status==="success"?"secondary":"destructive"}>{r.status==="success"?"发送成功":"发送失败"}</Badge>{r.sent_at&&<div className="mt-1 text-xs text-muted-foreground">{fmtTime(r.sent_at)}</div>}</TableCell>
<TableCell className="min-w-48 max-w-80 break-words text-xs">{r.error_msg||"—"}{r.error_code!=null&&<div className="mt-1 text-muted-foreground">微信错误码:{r.error_code}</div>}{r.http_status!=null&&<div className="text-muted-foreground">HTTP:{r.http_status}</div>}</TableCell>
<TableCell className="whitespace-nowrap text-xs">{r.attempts} 次<div className="text-muted-foreground">{r.duration_ms} ms</div></TableCell>
<TableCell><details className="min-w-40 text-xs"><summary className="cursor-pointer text-primary">查看详情</summary><div className="mt-2 space-y-1 break-all"><div>活动ID:{r.activity_id}</div><div>模板:{r.template_type}</div><div>模板ID:{r.template_id||"未配置"}</div><div>跳转页面:{r.page}</div></div></details></TableCell>
</TableRow>)}</TableBody>
</Table><Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage}/></div>}
</CardContent></Card>
</div>
}