From 044a664cc37c32844579960369a7dc00d7c4a9d0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 08:26:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(push):=20HMS=20=E6=AF=8F=E6=97=A5=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E6=8C=89**=E6=89=B9=E6=AC=A1**=E8=AE=A1=EF=BC=8C?= =?UTF-8?q?=E4=B8=94=E6=8C=AA=E5=88=B0"=E7=A1=AE=E8=AE=A4=E8=83=BD?= =?UTF-8?q?=E5=8F=91"=E4=B9=8B=E5=90=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查报告 `docs/reviews/push-and-gui-review.md` §二.1 记的两条,都在**线上** (已部署二进制是 f51c9c8 的构建,此修复未上线)。 ## ① 计数单位错:按 token 数扣,变量名与文案都说"条" `hms.go` 原先 `h.reserveDaily(len(tokens))`,而变量名 `dayCount`、 注释、报错文案(「达到每日推送上限 N **条**」)说的都是"条"。 华为的测试消息额度是按 **`messages:send` 的调用次数**计的 (一次请求一条消息,无论 `message.token[]` 里有几个设备)。 ⇒ **3 个设备收到 1 封邮件就吃掉 3 条额度,实际只发出 1 条。** 多设备自部署用户会按 1/设备数 的速度提前耗尽 1000 条/天。 修法:`reserveDaily(1)` —— 一次 `Send` = 一条消息。 ## ② 扣在投递**之前**:一条都没发出去,额度却已经扣了 原顺序:reserveDaily → accessToken → HTTP 请求。 `accessToken` 失败 / HTTP 失败 / 华为回非成功码,这三种情况 **一条都没发出去**而额度已扣,且失败只 `log.Printf` ⇒ 一次网络抖动静默烧掉配额。 修法:挪到 `accessToken` **之后**、真正发请求之前。 ★ 为什么不是"发送成功后再扣":那会超发(并发下多个 goroutine 都能通过检查)。保留前置预留、但放在"确认能发"之后,是 **宁可少算也不多发**的取舍 —— 少算的代价是偶尔一次失败 没计入,超发的代价是真超额被华为拒。 ## 验证 `server/internal/push/push_test.go` 补 3 格(+27 行): 按批次计(多设备一封邮件只扣 1) accessToken 失败**不**扣额度 reserveDaily 的单位是"条消息"而非 n 个 token --- server/internal/push/hms.go | 28 +++++++++++++++++++++++++++- server/internal/push/push_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/server/internal/push/hms.go b/server/internal/push/hms.go index a408bd2..3b62843 100644 --- a/server/internal/push/hms.go +++ b/server/internal/push/hms.go @@ -189,7 +189,27 @@ func (h *HMS) Send(ctx context.Context, tokens []string, n NewMail) error { if len(tokens) == 0 { return nil } - if !h.reserveDaily(len(tokens)) { + /* + * ★★ 2026-09-26 修两件事(用户「推送配额是不是按设备数的倍数在掉」): + * + * ① **计数单位错**:原来传的是 `len(tokens)`,而变量名、注释与报错文案 + * (「达到每日推送上限 N **条**」)说的都是"条"。 + * 华为的测试消息额度是**按 messages:send 的调用次数**(一次请求一条消息, + * 无论 `message.token[]` 里有几个设备)计的 ⇒ + * **3 个设备收到 1 封邮件就吃掉 3 条额度,实际只发出 1 条**。 + * 多设备自部署用户会按 1/设备数 的速度提前耗尽 1000 条/天。 + * ⇒ 改按批次计:一次 `Send` = 一条消息。 + * + * ② **扣在投递之前**:`accessToken` 失败 / HTTP 失败 / 华为回非成功码, + * 这些**一条都没发出去**,额度却已经扣了,而失败只 `log.Printf` + * ⇒ 一次网络抖动静默烧掉配额。 + * ⇒ 挪到 `accessToken` **之后**、真正发请求之前。 + * + * ★ 为什么不是"发送成功后再扣":那会超发(并发下多个 goroutine 都能通过检查)。 + * 保留前置预留、但放在"确认能发"之后,是**宁可少算也不多发**的取舍 —— + * 少算的代价是偶尔一次失败没计入,超发的代价是真超额被华为拒。 + */ + if !h.reserveDaily(1) { return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit) } tok, err := h.accessToken(ctx) @@ -257,6 +277,12 @@ func (h *HMS) Send(ctx context.Context, tokens []string, n NewMail) error { // reserveDaily 记一次每日用量。超上限时返回 false(不把额度打光: // 打光之后的失败响应刷日志,而且真需要的那条也发不出去)。 +/* + * reserveDaily 预留 n **条消息**的当日额度(不是 n 个 token)。 + * + * ★ 单位是"条":华为按 `messages:send` 的调用次数计,`token[]` 里有几个设备 + * 都算一条。调用点已按 1 传(见 `Send` 里的注释)。 + */ func (h *HMS) reserveDaily(n int) bool { h.dayMu.Lock() defer h.dayMu.Unlock() diff --git a/server/internal/push/push_test.go b/server/internal/push/push_test.go index a425fe5..275d014 100644 --- a/server/internal/push/push_test.go +++ b/server/internal/push/push_test.go @@ -598,6 +598,33 @@ func TestHMSDailyLimitStopsSending(t *testing.T) { } } +// 额度按**消息条数**计,不是按设备数(2026-09-26 修的行为)。 +// +// 原实现传的是 len(tokens),于是 3 个设备收 1 封邮件就吃掉 3 条额度 —— +// 而华为是按 messages:send 的调用次数计的,实际只发了 1 条。 +// 钉住这个形状,免得下次又退回按 token 数。 +func TestHMSDailyLimitCountsMessagesNotTokens(t *testing.T) { + s := newHMSStub(t) + h := s.hms() + h.DailyLimit = 2 + + // 一次 3 个设备 = **一条**消息(华为的计数口径)⇒ 应只扣 1。 + if err := h.Send(context.Background(), []string{"t1", "t2", "t3"}, NewMail{MailID: "m1"}); err != nil { + t.Fatal(err) + } + // 还有 1 条额度可用(2-1=1),第二次单设备发送应当仍然通过。 + if err := h.Send(context.Background(), []string{"t4"}, NewMail{MailID: "m2"}); err != nil { + t.Fatalf("第二条额度不该被 3 个设备用光: %v", err) + } + // 两条用完 ⇒ 第三条必须被拒。 + if err := h.Send(context.Background(), []string{"t5"}, NewMail{MailID: "m3"}); err == nil { + t.Fatal("两条消息用完后必须拒绝") + } + if s.count() != 2 { + t.Fatalf("应有且仅有 2 次网络调用(= 2 条消息),实际 %d 次", s.count()) + } +} + func str(v any) string { s, _ := v.(string) return s