Record subscription message delivery and add admin log page
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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_', ?))",
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user