diff --git a/server/internal/push/hms.go b/server/internal/push/hms.go index 3b62843..eb7afbf 100644 --- a/server/internal/push/hms.go +++ b/server/internal/push/hms.go @@ -200,22 +200,25 @@ func (h *HMS) Send(ctx context.Context, tokens []string, n NewMail) error { * 多设备自部署用户会按 1/设备数 的速度提前耗尽 1000 条/天。 * ⇒ 改按批次计:一次 `Send` = 一条消息。 * - * ② **扣在投递之前**:`accessToken` 失败 / HTTP 失败 / 华为回非成功码, - * 这些**一条都没发出去**,额度却已经扣了,而失败只 `log.Printf` + * ② **扣在投递之前**:原顺序是 reserveDaily → accessToken → HTTP 请求。 + * `accessToken` 失败 / HTTP 失败 / 华为回非成功码,这些**一条都没发出去**, + * 额度却已经扣了,而失败只 `log.Printf` * ⇒ 一次网络抖动静默烧掉配额。 - * ⇒ 挪到 `accessToken` **之后**、真正发请求之前。 + * ⇒ 预留挪到 `accessToken` **之后**、真正发请求之前(见下方调用点)。 * - * ★ 为什么不是"发送成功后再扣":那会超发(并发下多个 goroutine 都能通过检查)。 - * 保留前置预留、但放在"确认能发"之后,是**宁可少算也不多发**的取舍 —— - * 少算的代价是偶尔一次失败没计入,超发的代价是真超额被华为拒。 + * ★ 为什么不是"发送成功后再扣":那会超发(并发下多个 goroutine + * 都能通过检查)。保留前置预留、但放在"确认能发"之后,是**宁可少算也不多发** + * 的取舍 —— 少算的代价是偶尔一次失败没计入,超发的代价是真超额被华为拒。 */ - if !h.reserveDaily(1) { - return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit) - } tok, err := h.accessToken(ctx) if err != nil { return err } + // 必须在 accessToken **之后**预留(②):拿不到 token 就一条都发不出去。 + // 挪回上面一行会让 DailyLimit=1 时一次网络抖动烧穿当日配额。 + if !h.reserveDaily(1) { + return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit) + } data, _ := json.Marshal(map[string]string{ // 与客户端约定的形状(见文档与给 dsh 的契约):点通知按它跳转。 "type": "new_mail", diff --git a/server/internal/push/push_test.go b/server/internal/push/push_test.go index 275d014..57f2678 100644 --- a/server/internal/push/push_test.go +++ b/server/internal/push/push_test.go @@ -415,6 +415,13 @@ type hmsStub struct { pushReqs []map[string]any authHdrs []string code string + // failToken 让 /token 返回 400(模拟 accessToken 失败)。 + // + // 为什么需要它(2026-09-28):审查报告指出「额度扣在投递之前」—— + // accessToken 失败时**一条都没发出去**、额度却已经扣了。 + // 而当时那批判据**测不出这件事**:造不出 token 失败这条路。 + // 修完代码后补这一格,就是为了让「注释说修了」与「代码真修了」不再能分家。 + failToken bool } func newHMSStub(t *testing.T) *hmsStub { @@ -424,7 +431,14 @@ func newHMSStub(t *testing.T) *hmsStub { if r.URL.Path == "/token" { s.mu.Lock() s.tokenReq++ + fail := s.failToken s.mu.Unlock() + if fail { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"invalid_token"}`) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"tok-abc","expires_in":3600}`) return @@ -629,3 +643,61 @@ func str(v any) string { s, _ := v.(string) return s } + +// ── 2026-09-28:额度必须在「确认能发」之后才扣 ──────────────────────── +// +// 这格判据是**补的**,而它本该在 2026-09-26 那次修复时就存在。 +// +// 当时发生的事:审查报告 §二.1 写了两条(①按 token 数扣 ②扣在投递之前), +// 实现只改了 ①,而 ② **被写进了注释、代码没动** —— `reserveDaily` 仍在 +// `accessToken` 之前。注释与代码分家,而当时那批判据测不出来: +// 造不出「accessToken 失败」这条路。 +// +// 抓住它的是 pi:它在一次邮件驱动的回合里自己发现了这处不一致。 +func TestHMSAccessTokenFailureDoesNotBurnQuota(t *testing.T) { + s := newHMSStub(t) + h := s.hms() + h.DailyLimit = 5 + + s.mu.Lock() + s.failToken = true + s.mu.Unlock() + + // 三次都应该在拿 token 这一步就失败 + for i := 0; i < 3; i++ { + if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"}); err == nil { + t.Fatalf("第 %d 次:accessToken 失败时 Send 必须返回错误", i+1) + } + } + + s.mu.Lock() + pushes := len(s.pushReqs) + s.failToken = false + s.mu.Unlock() + + if pushes != 0 { + t.Fatalf("accessToken 失败时不该发出任何推送,实际发了 %d 次", pushes) + } + + // ★ 核心断言:额度**一点没扣**。 + // 若预留仍在 accessToken 之前,这里的 used 就是 3, + // DailyLimit=5 也会只剩 2 次机会 —— 而这三次什么都没发出去。 + h.dayMu.Lock() + used := h.dayCount + h.dayMu.Unlock() + if used != 0 { + t.Fatalf("★ accessToken 失败不该扣额度,实际已扣 %d 条("+ + "一次网络抖动静默烧配额就是这么来的)", used) + } + + // 恢复正常后仍能发送 ⇒ 额度没被那三次失败吃掉 + if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m-ok"}); err != nil { + t.Fatalf("恢复后必须能发送: %v", err) + } + h.dayMu.Lock() + used = h.dayCount + h.dayMu.Unlock() + if used != 1 { + t.Fatalf("成功这一次应恰好计 1 条,实际 %d", used) + } +}