diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d557d4..d7e3648 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
# Changelog
+## 开发快照 0.1.368(周报发送窗口与正式补发修复)
+
+### Fixed
+
+- 修复周报自动发送在“周日 23:00”这类配置下必然空转的问题:发送窗口现在按配置的发送日
+ 推导要发的周(周日窗口发当天结束的那一周,其它工作日仍发上一完整周),不再等周一的
+ 零点——那一刻配置的窗口已经过去。周一 09:00 的原有语义保持不变。
+- 自动发送只处理该窗口对应的那一周,不再按“所有已结束的周”补发,避免一次失败演变成
+ 无界历史回填;其它周如有需要,由管理员显式补发。
+- 修正发送记录认领逻辑:只有 failed(或首次)记录会被重新认领,已发送(sent)返回幂等
+ 结果并带出真实 message_id;上次结果未知的“发送中”记录不再被盲目重发,而是明确拒绝
+ 并提示先核对邮箱。自动发送与手工补发并发或重复点击时只投递一次。
+- 失败不再笼统显示“稍后重试”,发送记录与界面都带上真实 SMTP 原因;失败/结果未知都不
+ 会写成功。
+
+### Added
+
+- 新增管理员“正式补发”入口(周报页按钮 + `POST /api/v1/qa-ops/master/weekly-mail/send/{week}`)。
+ 必须登录管理员、同源操作意图,并精确指定周、已确认版本与正文 hash;只允许已结束的周,
+ 当前周需等到设定时点,未来周一律拒绝。正文、看板图片、主送/抄送与自动发送共用同一条
+ 渲染与投递路径,正式收件人照旧;确认框显示真实收件人数与版本,补发结果写入正式发送
+ 记录并可读回 message_id。它与“发送测试邮件”(只发 review 地址、不登记)是两件事。
+
+### Tests
+
+- 真实隔离 PostgreSQL:周日 23:00 当周发送 RED/GREEN(旧实现真实复现 nothing_confirmed)、
+ 到点前/未来周/未确认/正文或版本变化拒绝、周一发上一周的语义保持、手工补发只投递一次
+ 并读回 message_id、未来周与未到时点被拒、自动+手动并发只投递一次、结果未知不重发、
+ 失败如实记录且可重试一次。
+- HTTP 与前端:补发接口仅管理员+同源意图、拒绝原因具体、真实失败返回 502、预览显示正式
+ 收件人数;前端解析拒绝无 message_id 的“成功”。
+
## 开发快照 0.1.366(启动收敛误判修复与误判核对审计)
### Fixed
diff --git a/api/openapi.yaml b/api/openapi.yaml
index d15d2f1..87ace2e 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -52,6 +52,7 @@
"/api/v1/qa-ops/master/weekly-mail/confirmations/{week}": {"get":{"operationId":"getQAWeeklyMailConfirmation","tags":["QA operations"],"summary":"读取该周是否已确认自动发送;current=false表示确认后正文已被修改,需要重新确认","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}],"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可修改发送设置"},"409":{"description":"发送设置已被其他管理员修改"},"503":{"description":"邮件设置存储不可用,未标记为已发送"}}},"post":{"operationId":"confirmQAWeeklyMail","tags":["QA operations"],"summary":"确认本周周报可自动发送,绑定当前草稿版本与正文哈希;后续改动使确认失效,不发送改动后的正文","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-mail"}}],"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可修改发送设置"},"409":{"description":"发送设置已被其他管理员修改"},"503":{"description":"邮件设置存储不可用,未标记为已发送"}}},"delete":{"operationId":"unconfirmQAWeeklyMail","tags":["QA operations"],"summary":"取消该周的自动发送确认","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-mail"}}],"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可修改发送设置"},"409":{"description":"发送设置已被其他管理员修改"},"503":{"description":"邮件设置存储不可用,未标记为已发送"}}}},
"/api/v1/qa-ops/master/weekly-mail/sends": {"get":{"operationId":"listQAWeeklyMailSends","tags":["QA operations"],"summary":"读取周报邮件发送记录:周次、草稿版本、发送中/已发送/失败及错误摘要,不返回正文或收件人","parameters":[{"name":"week","in":"query","required":false,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}],"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可修改发送设置"},"409":{"description":"发送设置已被其他管理员修改"},"503":{"description":"邮件设置存储不可用,未标记为已发送"}}}},
"/api/v1/qa-ops/master/weekly-mail/test": {"post":{"operationId":"sendQAWeeklyMailTest","tags":["QA operations"],"summary":"管理员用当前已保存的邮件设置立即发送一封测试邮件,正文取该周已保存草稿;不写入发送记录、不改变确认状态、不把该周标记为已发送","parameters":[{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-mail"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["week"],"properties":{"week":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}}}}},"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可测试发送"},"502":{"description":"SMTP 拒绝或不可达,返回脱敏原因"},"503":{"description":"邮件设置存储不可用"}}}},
+ "/api/v1/qa-ops/master/weekly-mail/send/{week}": {"get":{"operationId":"previewQAWeeklyMailSend","tags":["QA operations"],"summary":"管理员补发前核对:按周、已确认版本与正文hash返回真实主送/抄送人数、版本与是否已发送;不投递、不写记录","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"revision","in":"query","required":true,"schema":{"type":"integer","minimum":1}},{"name":"body_sha256","in":"query","required":true,"schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"409":{"description":"未确认/正文或版本已变化/当前周未到设定时点/上次结果未知"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"邮件设置存储不可用"}}},"post":{"operationId":"sendQAWeeklyMailFormal","tags":["QA operations"],"summary":"管理员正式补发周报:精确指定周、已确认版本与正文hash;已结束周可发、当前周须到设定时点、未来周拒绝;正文/看板图片/主送/抄送与自动发送共用,写入正式发送记录并返回真实message_id;已发送幂等、结果未知不重发","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-mail"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["revision","body_sha256"],"properties":{"revision":{"type":"integer","minimum":1},"body_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"}}}}}},"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且需同源操作意图"},"409":{"description":"未确认/正文或版本已变化/当前周未到设定时点/上次结果未知/授权码缺失"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"description":"SMTP 拒绝或不可达,返回脱敏原因且不写已发送"},"503":{"description":"邮件设置存储不可用"}}}},
"/api/v1/qa-ops/master/weekly-mail/confirmations/{week}/image": {"post":{"operationId":"saveQAWeeklyMailImage","tags":["QA operations"],"summary":"管理员保存该周确认对应的统计看板 PNG;发送时作为内嵌图片(cid)放在正文末尾,正文改动使确认失效后不再使用旧图","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-mail"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["png_base64"],"properties":{"png_base64":{"type":"string","maxLength":12000000}}}}}},"responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"401":{"description":"需要登录"},"403":{"description":"仅管理员可上传"},"429":{"description":"请求过快"},"503":{"description":"邮件设置存储不可用"}}}},
"/api/v1/qa-ops/departments/statistics": {
"post": {
diff --git a/internal/httpapi/qa_weekly_mail.go b/internal/httpapi/qa_weekly_mail.go
index 05a1daf..6b91c04 100644
--- a/internal/httpapi/qa_weekly_mail.go
+++ b/internal/httpapi/qa_weekly_mail.go
@@ -7,6 +7,7 @@ import (
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qawemail"
"net/http"
+ "strconv"
"time"
)
@@ -20,6 +21,96 @@ type weeklyMailStore interface {
TestSend(context.Context, string) (string, error)
SaveDashboardImage(context.Context, string, []byte) error
SendDue(context.Context, time.Time) (qawemail.DueResult, error)
+ PreviewSend(context.Context, string, int64, string, time.Time) (qawemail.SendPreview, error)
+ SendManual(context.Context, string, int64, string, string, time.Time) (qawemail.ManualResult, error)
+}
+
+// weeklyMailSendError names the rule that refused a manual resend. Anything SMTP-related is
+// reported by the handler itself, so an unmet precondition never looks like a mail failure.
+func weeklyMailSendError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, qawemail.ErrNotCurrent):
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "该周周报未确认,或确认后正文/版本已变化;请先确认当前版本再补发"})
+ case errors.Is(err, qawemail.ErrFutureWeek):
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不能补发未来周"})
+ case errors.Is(err, qawemail.ErrSlotPending):
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "本周尚未到达设定的发送时点,暂不能补发"})
+ case errors.Is(err, qawemail.ErrNotConfigured):
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "未配置邮件服务器、发件人或收件人,无法补发"})
+ case errors.Is(err, qawemail.ErrSendInFlight):
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "该周该版本有一次发送结果未知(记录停在发送中),为避免重复投递未重发;请先核对已发出的邮件"})
+ case errors.Is(err, qawemail.ErrNoPassword):
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "SMTP 授权码缺失或无法解密,无法补发"})
+ case errors.Is(err, qawemail.ErrInvalid):
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "补发参数无效:需要精确的周、已确认版本与正文 hash"})
+ default:
+ weeklyMailError(w, err)
+ }
+}
+
+// qaWeeklyMailSendPreview lets the confirmation dialog show the exact week, revision and
+// distribution this send would use — the same settings the send path reads.
+func qaWeeklyMailSendPreview(w http.ResponseWriter, r *http.Request, c Config) {
+ if !authorizeQAOps(w, r) {
+ return
+ }
+ store, ok := c.QAWeeklyMail.(weeklyMailStore)
+ if !ok {
+ weeklyMailError(w, qawemail.ErrUnavailable)
+ return
+ }
+ query := r.URL.Query()
+ revision, err := strconv.ParseInt(query.Get("revision"), 10, 64)
+ if err != nil {
+ weeklyMailSendError(w, qawemail.ErrInvalid)
+ return
+ }
+ preview, err := store.PreviewSend(r.Context(), r.PathValue("week"), revision, query.Get("body_sha256"), time.Now())
+ if err != nil {
+ weeklyMailSendError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"data": preview})
+}
+
+// qaWeeklyMailSend is the formal resend: admin only, bound to the live confirmation, and the same
+// render/claim path as the automatic slot. It is not a test send and never targets the review box.
+func qaWeeklyMailSend(w http.ResponseWriter, r *http.Request, c Config) {
+ if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-weekly-mail") {
+ return
+ }
+ identity, ok := sessionFrom(r.Context())
+ if !ok || identity.Role != "admin" {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "仅管理员可正式补发周报"})
+ return
+ }
+ store, found := c.QAWeeklyMail.(weeklyMailStore)
+ if !found {
+ weeklyMailError(w, qawemail.ErrUnavailable)
+ return
+ }
+ var request struct {
+ Revision int64 `json:"revision"`
+ BodySHA256 string `json:"body_sha256"`
+ }
+ if decodeQualityJSON(w, r, &request) != nil {
+ weeklyMailError(w, qawemail.ErrInvalid)
+ return
+ }
+ result, err := store.SendManual(r.Context(), r.PathValue("week"), request.Revision, request.BodySHA256, identity.Username, time.Now())
+ if err != nil {
+ weeklyMailSendError(w, err)
+ return
+ }
+ if result.Status == "failed" {
+ reason := result.Reason
+ if len(reason) > 300 {
+ reason = reason[:300]
+ }
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "正式补发未送达:" + reason})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"data": result})
}
func weeklyMailError(w http.ResponseWriter, err error) {
diff --git a/internal/httpapi/qa_weekly_mail_test.go b/internal/httpapi/qa_weekly_mail_test.go
new file mode 100644
index 0000000..748d57e
--- /dev/null
+++ b/internal/httpapi/qa_weekly_mail_test.go
@@ -0,0 +1,186 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "knowledge-governance/internal/qawemail"
+)
+
+type fakeWeeklyMailStore struct {
+ preview qawemail.SendPreview
+ previewErr error
+ result qawemail.ManualResult
+ sendErr error
+ calls int
+ lastWeek string
+ lastRev int64
+ lastHash string
+ lastActor string
+}
+
+func (f *fakeWeeklyMailStore) ReadSettings(context.Context) (qawemail.Settings, error) {
+ return qawemail.Settings{}, nil
+}
+func (f *fakeWeeklyMailStore) SaveSettings(context.Context, string, qawemail.SettingsInput) (qawemail.Settings, error) {
+ return qawemail.Settings{}, nil
+}
+func (f *fakeWeeklyMailStore) Confirm(context.Context, string, string) (qawemail.Confirmation, error) {
+ return qawemail.Confirmation{}, nil
+}
+func (f *fakeWeeklyMailStore) Unconfirm(context.Context, string) error { return nil }
+func (f *fakeWeeklyMailStore) ReadConfirmation(context.Context, string) (*qawemail.Confirmation, error) {
+ return nil, nil
+}
+func (f *fakeWeeklyMailStore) Sends(context.Context, string) ([]qawemail.Send, error) {
+ return nil, nil
+}
+func (f *fakeWeeklyMailStore) TestSend(context.Context, string) (string, error) { return "", nil }
+func (f *fakeWeeklyMailStore) SaveDashboardImage(context.Context, string, []byte) error {
+ return nil
+}
+func (f *fakeWeeklyMailStore) SendDue(context.Context, time.Time) (qawemail.DueResult, error) {
+ return qawemail.DueResult{}, nil
+}
+func (f *fakeWeeklyMailStore) PreviewSend(_ context.Context, week string, revision int64, hash string, _ time.Time) (qawemail.SendPreview, error) {
+ f.lastWeek, f.lastRev, f.lastHash = week, revision, hash
+ if f.previewErr != nil {
+ return qawemail.SendPreview{}, f.previewErr
+ }
+ out := f.preview
+ out.Week, out.Revision, out.BodySHA256 = week, revision, hash
+ return out, nil
+}
+func (f *fakeWeeklyMailStore) SendManual(_ context.Context, week string, revision int64, hash, actor string, _ time.Time) (qawemail.ManualResult, error) {
+ f.calls++
+ f.lastWeek, f.lastRev, f.lastHash, f.lastActor = week, revision, hash, actor
+ if f.sendErr != nil {
+ return qawemail.ManualResult{}, f.sendErr
+ }
+ out := f.result
+ out.Week, out.Revision = week, revision
+ return out, nil
+}
+
+func weeklyMailSendRequest(t *testing.T, role, body string, withIntent bool) *http.Request {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/qa-ops/master/weekly-mail/send/2026-W45", strings.NewReader(body))
+ request.SetPathValue("week", "2026-W45")
+ request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: role}))
+ if withIntent {
+ request.Header.Set("X-Governance-Action", "qa-weekly-mail")
+ }
+ return request
+}
+
+// 正式补发是管理员动作:普通角色与缺少同源意图都必须被挡在 SMTP 之前。
+func TestWeeklyMailFormalSendRequiresAdminAndIntent(t *testing.T) {
+ store := &fakeWeeklyMailStore{result: qawemail.ManualResult{Status: "sent", MessageID: ""}}
+ config := Config{QAWeeklyMail: store}
+ body := `{"revision":16,"body_sha256":"` + strings.Repeat("a", 64) + `"}`
+
+ recorder := httptest.NewRecorder()
+ qaWeeklyMailSend(recorder, weeklyMailSendRequest(t, "engineer", body, true), config)
+ if recorder.Code != http.StatusForbidden {
+ t.Fatalf("非管理员必须 403,status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ recorder = httptest.NewRecorder()
+ qaWeeklyMailSend(recorder, weeklyMailSendRequest(t, roleAdmin, body, false), config)
+ if recorder.Code != http.StatusForbidden {
+ t.Fatalf("缺少同源操作意图必须 403,status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ if store.calls != 0 {
+ t.Fatalf("被拒请求不得调用发送:calls=%d", store.calls)
+ }
+}
+
+// 成功补发必须把 week/版本/hash/操作人原样交给 store,并回传真实 message_id 与收件人数。
+func TestWeeklyMailFormalSendPassesExactRevisionAndReturnsReceipt(t *testing.T) {
+ store := &fakeWeeklyMailStore{result: qawemail.ManualResult{Status: "sent", MessageID: "", Recipients: 119, Cc: 8}}
+ config := Config{QAWeeklyMail: store}
+ hash := strings.Repeat("b", 64)
+ recorder := httptest.NewRecorder()
+ qaWeeklyMailSend(recorder, weeklyMailSendRequest(t, roleAdmin, `{"revision":16,"body_sha256":"`+hash+`"}`, true), config)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("管理员补发应成功,status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ if store.lastWeek != "2026-W45" || store.lastRev != 16 || store.lastHash != hash || store.lastActor != "tester" {
+ t.Fatalf("补发参数没有精确传递:week=%q rev=%d hash=%q actor=%q", store.lastWeek, store.lastRev, store.lastHash, store.lastActor)
+ }
+ var payload struct {
+ Data qawemail.ManualResult `json:"data"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.Data.MessageID != "" || payload.Data.Recipients != 119 || payload.Data.Cc != 8 {
+ t.Fatalf("响应必须带回真实发送凭据与正式收件人数:%+v", payload.Data)
+ }
+}
+
+// 前置条件不满足必须给出具体原因,而不是冒充 SMTP 失败。
+func TestWeeklyMailFormalSendExplainsRefusalsInsteadOfSMTPFailure(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ status int
+ contains string
+ }{
+ {"future", qawemail.ErrFutureWeek, http.StatusBadRequest, "未来周"},
+ {"slot", qawemail.ErrSlotPending, http.StatusConflict, "尚未到达设定的发送时点"},
+ {"stale", qawemail.ErrNotCurrent, http.StatusConflict, "正文/版本已变化"},
+ {"inflight", qawemail.ErrSendInFlight, http.StatusConflict, "结果未知"},
+ {"nopassword", qawemail.ErrNoPassword, http.StatusConflict, "授权码"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ store := &fakeWeeklyMailStore{sendErr: tc.err}
+ recorder := httptest.NewRecorder()
+ qaWeeklyMailSend(recorder, weeklyMailSendRequest(t, roleAdmin, `{"revision":16,"body_sha256":"`+strings.Repeat("c", 64)+`"}`, true), Config{QAWeeklyMail: store})
+ if recorder.Code != tc.status || !strings.Contains(recorder.Body.String(), tc.contains) {
+ t.Fatalf("%s 应 %d 且说明原因 %q,status=%d body=%s", tc.name, tc.status, tc.contains, recorder.Code, recorder.Body.String())
+ }
+ })
+ }
+}
+
+// SMTP 真实失败:必须 502 且带上真实原因,绝不能回“已发送”。
+func TestWeeklyMailFormalSendReportsDeliveryFailure(t *testing.T) {
+ store := &fakeWeeklyMailStore{result: qawemail.ManualResult{Status: "failed", Reason: "发送失败:smtp 登录失败: 535"}}
+ recorder := httptest.NewRecorder()
+ qaWeeklyMailSend(recorder, weeklyMailSendRequest(t, roleAdmin, `{"revision":16,"body_sha256":"`+strings.Repeat("d", 64)+`"}`, true), Config{QAWeeklyMail: store})
+ if recorder.Code != http.StatusBadGateway || !strings.Contains(recorder.Body.String(), "535") {
+ t.Fatalf("未送达必须 502 并带真实原因,status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
+// 补发前预览必须给出真实 week/版本/收件人数,用于确认框;预览本身不投递。
+func TestWeeklyMailSendPreviewShowsExactDistribution(t *testing.T) {
+ store := &fakeWeeklyMailStore{preview: qawemail.SendPreview{Status: "ready", Recipients: 119, Cc: 8}}
+ hash := strings.Repeat("e", 64)
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/qa-ops/master/weekly-mail/send/2026-W45?revision=16&body_sha256="+hash, nil)
+ request.SetPathValue("week", "2026-W45")
+ request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: roleAdmin}))
+ recorder := httptest.NewRecorder()
+ qaWeeklyMailSendPreview(recorder, request, Config{QAWeeklyMail: store})
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("预览应成功,status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ var payload struct {
+ Data qawemail.SendPreview `json:"data"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.Data.Recipients != 119 || payload.Data.Cc != 8 || payload.Data.Revision != 16 || payload.Data.Status != "ready" {
+ t.Fatalf("预览必须给出正式收件人数与版本:%+v", payload.Data)
+ }
+ if store.calls != 0 {
+ t.Fatal("预览不得触发发送")
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 621d825..10229ef 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -282,6 +282,8 @@ func NewHandler(config Config) (http.Handler, error) {
mux.HandleFunc("DELETE /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/sends", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSends(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/test", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailTest(w, r, config) })
+ mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/send/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSendPreview(w, r, config) })
+ mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/send/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSend(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/confirmations/{week}/image", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailImage(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/field-options", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldOptions(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/records/{id}/fields", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldPatch(w, r, config) })
diff --git a/internal/qawemail/resend_integration_test.go b/internal/qawemail/resend_integration_test.go
new file mode 100644
index 0000000..2b71828
--- /dev/null
+++ b/internal/qawemail/resend_integration_test.go
@@ -0,0 +1,457 @@
+package qawemail
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ postgresstore "knowledge-governance/internal/platform/postgres"
+)
+
+// countingMailer records real deliveries instead of talking to SMTP. It is shared by the
+// concurrency case, so every observation happens under the mutex.
+type countingMailer struct {
+ mu sync.Mutex
+ calls int
+ delay time.Duration
+ err error
+ subjects []string
+ to [][]string
+ cc [][]string
+ html []string
+ messageIDs []string
+}
+
+func (m *countingMailer) Send(_ context.Context, _ SMTPConfig, msg Message) (string, error) {
+ if m.delay > 0 {
+ time.Sleep(m.delay)
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.calls++
+ if m.err != nil {
+ return "", m.err
+ }
+ id := fmt.Sprintf("", m.calls)
+ m.subjects = append(m.subjects, msg.Subject)
+ m.to = append(m.to, msg.To)
+ m.cc = append(m.cc, msg.Cc)
+ m.html = append(m.html, msg.HTML)
+ m.messageIDs = append(m.messageIDs, id)
+ return id, nil
+}
+
+func (m *countingMailer) count() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.calls
+}
+
+// weeklyMailFixture applies the real schema to an explicitly disposable database. Refusing to
+// run without KG_WEEKLY_MAIL_TEST_DSN keeps CI honest: a skip there is not a pass.
+func weeklyMailFixture(t *testing.T) (*Store, *pgxpool.Pool, *countingMailer) {
+ t.Helper()
+ dsn := os.Getenv("KG_WEEKLY_MAIL_TEST_DSN")
+ if dsn == "" {
+ t.Skip("显式一次性周报邮件数据库未提供(KG_WEEKLY_MAIL_TEST_DSN):跳过不等于通过")
+ }
+ // The name is the only proof this is disposable; never touch a governance database.
+ if !strings.Contains(dsn, "/kg_weekly_mail_test_") || strings.Contains(dsn, "/governance?") {
+ t.Fatal("拒绝非一次性周报测试库")
+ }
+ ctx := context.Background()
+ pool, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ // Deliberately do not print the DSN (it carries a password).
+ t.Fatal("测试库连接参数无效(详情省略)")
+ }
+ t.Cleanup(pool.Close)
+ if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ mailer := &countingMailer{}
+ store := NewStore(pool, "fixture-weekly-resend", os.Getenv("KG_WEEKLY_MAIL_TEST_KEY"), mailer)
+ for _, statement := range []string{
+ `DELETE FROM qa_weekly_email_sends WHERE tenant_key=$1`,
+ `DELETE FROM qa_weekly_confirmations WHERE tenant_key=$1`,
+ `DELETE FROM qa_weekly_email_settings WHERE tenant_key=$1`,
+ `DELETE FROM qa_ops_weekly_drafts WHERE tenant_key=$1`,
+ } {
+ if _, err := pool.Exec(ctx, statement, store.tenant); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return store, pool, mailer
+}
+
+func seedWeeklyDraft(t *testing.T, pool *pgxpool.Pool, tenant, week string, revision int64, markdown string) {
+ t.Helper()
+ if _, err := pool.Exec(context.Background(), `INSERT INTO qa_ops_weekly_drafts(tenant_key,week,revision,markdown,seed,updated_by,updated_at)
+ VALUES($1,$2,$3,$4,'{}'::jsonb,'fixture-admin',now())
+ ON CONFLICT (tenant_key,week) DO UPDATE SET revision=excluded.revision,markdown=excluded.markdown`, tenant, week, revision, markdown); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func enabledWeeklySettings(t *testing.T, store *Store, weekday, hour int) Settings {
+ t.Helper()
+ settings, err := store.SaveSettings(context.Background(), "fixture-admin", SettingsInput{
+ BaseRevision: 0, Host: "localhost", Port: 465, Username: "kb@example.com", Password: "app-password",
+ From: "kb@example.com", Recipients: []string{"lead@example.com", "pm@example.com"},
+ Cc: []string{"qa@example.com"}, Review: "owner@example.com", Weekday: weekday, Hour: hour, Enabled: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return settings
+}
+
+func weekLocal(t *testing.T, year int, month time.Month, day, hour int) time.Time {
+ t.Helper()
+ return time.Date(year, month, day, hour, 5, 0, 0, time.FixedZone("CST", 8*3600))
+}
+
+// A: the reported week closes at the end of its own Sunday; the 周日23点 configuration must mail
+// that week inside the slot instead of waiting for the next Monday (where the slot is gone).
+func TestAutoSendAtSundaySlotMailsTheWeekThatEndsThatDay(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ md := "# 知识库周报 2026-W45\n\n## 二、核心指标\n\n正确率:91.20%\n"
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, md)
+ if _, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+
+ // 周六同一小时、周日22点:都还没到配置时点,不许提前发。
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 7, 23)); err != nil || result.State != "not_due" {
+ t.Fatalf("周六不是配置发送日,必须 not_due,got %+v %v", result, err)
+ }
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 22)); err != nil || result.State != "not_due" {
+ t.Fatalf("到点前必须 not_due,got %+v %v", result, err)
+ }
+ if mailer.count() != 0 {
+ t.Fatal("到点前不得投递")
+ }
+
+ sent, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23))
+ if err != nil || sent.State != "sent" || sent.Week != "2026-W45" {
+ t.Fatalf("周日23点必须发出当周 2026-W45,got %+v %v", sent, err)
+ }
+ if mailer.count() != 1 || sent.MessageID == "" {
+ t.Fatalf("应只投递一次并记录 message_id,calls=%d message_id=%q", mailer.count(), sent.MessageID)
+ }
+ message := mailer.to[0]
+ if len(message) != 2 || message[0] != "lead@example.com" {
+ t.Fatalf("自动发送必须用正式收件人而不是测试地址:%v", message)
+ }
+ if len(mailer.cc[0]) != 1 || mailer.cc[0][0] != "qa@example.com" {
+ t.Fatalf("自动发送必须带上抄送:%v", mailer.cc[0])
+ }
+
+ // 同一时段重复触发幂等;周一00:00已不在时段内,绝不能补发。
+ again, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23))
+ if err != nil || again.State != "already_sent" {
+ t.Fatalf("同一时段重复触发必须 already_sent,got %+v %v", again, err)
+ }
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 9, 0)); err != nil || result.State != "not_due" {
+ t.Fatalf("周一00:00已过时段,必须 not_due,got %+v %v", result, err)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("重复/越界触发造成了重复投递:calls=%d", mailer.count())
+ }
+ var sentRows int
+ if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM qa_weekly_email_sends WHERE tenant_key=$1 AND status='sent'`, store.tenant).Scan(&sentRows); err != nil {
+ t.Fatal(err)
+ }
+ if sentRows != 1 {
+ t.Fatalf("sends 表应只有一条 sent,got %d", sentRows)
+ }
+}
+
+// A: an unconfirmed week, a changed body and a future week must never be mailed automatically.
+func TestAutoSendRefusesUnconfirmedChangedAndFutureWeeks(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ enabledWeeklySettings(t, store, 7, 23)
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, "# 知识库周报 2026-W45\n\n正确率:91.20%\n")
+ // 未确认:不能发。
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23)); err != nil || result.State != "nothing_confirmed" {
+ t.Fatalf("未确认的周报必须 nothing_confirmed,got %+v %v", result, err)
+ }
+ if _, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ // 已确认后改正文:确认失效,不能发。
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 17, "# 知识库周报 2026-W45\n\n正确率:92.00%\n")
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23)); err != nil || result.State != "nothing_confirmed" {
+ t.Fatalf("正文变化后必须 nothing_confirmed,got %+v %v", result, err)
+ }
+ // 未来周(W46)即使已确认也不能在 W45 的时段被自动投递。
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W46", 1, "# 知识库周报 2026-W46\n")
+ if _, err := store.Confirm(context.Background(), "2026-W46", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23)); err != nil || result.State != "nothing_confirmed" {
+ t.Fatalf("未来周不得被自动发送,got %+v %v", result, err)
+ }
+ if mailer.count() != 0 {
+ t.Fatalf("不该有任何投递,calls=%d", mailer.count())
+ }
+}
+
+// Monday slots keep their old meaning: mail the week that ended, not the one that just began.
+func TestAutoSendOnMondayStillMailsThePreviousCompleteWeek(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, "# 知识库周报 2026-W45\n\n正确率:91.20%\n")
+ if _, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 1, 9)
+ result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 9, 9))
+ if err != nil || result.State != "sent" || result.Week != "2026-W45" {
+ t.Fatalf("周一09点应发送上一完整周 W45,got %+v %v", result, err)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("calls=%d, want 1", mailer.count())
+ }
+}
+
+// B/C: the manual resend uses the frozen confirmed revision and is idempotent.
+func TestManualResendSendsFrozenRevisionOnceAndReadsBackMessageID(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ md := "# 知识库周报 2026-W45\n\n## 二、核心指标\n\n正确率:91.20%\n"
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, md)
+ confirmation, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+ png := []byte("\x89PNG\r\n\x1a\nweekly-dashboard-fixture")
+ if err := store.SaveDashboardImage(context.Background(), "2026-W45", png); err != nil {
+ t.Fatal(err)
+ }
+ now := weekLocal(t, 2026, 11, 9, 0) // 周一00:32,真实故障时刻
+
+ preview, err := store.PreviewSend(context.Background(), "2026-W45", 16, confirmation.BodySHA256, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if preview.Status != "ready" || preview.Recipients != 2 || preview.Cc != 1 || preview.Revision != 16 {
+ t.Fatalf("补发预览必须给出真实收件人数与版本:%+v", preview)
+ }
+ if mailer.count() != 0 {
+ t.Fatal("预览不得投递")
+ }
+
+ sent, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", now)
+ if err != nil || sent.Status != "sent" {
+ t.Fatalf("正式补发必须成功:%+v %v", sent, err)
+ }
+ if sent.MessageID == "" || sent.Recipients != 2 || sent.Cc != 1 {
+ t.Fatalf("补发结果缺少 message_id 或收件人数:%+v", sent)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("calls=%d, want 1", mailer.count())
+ }
+ if strings.Contains(mailer.subjects[0], "测试发送") || strings.Contains(mailer.subjects[0], "2026-W45") == false {
+ t.Fatalf("正式补发主题不对:%q", mailer.subjects[0])
+ }
+ if len(mailer.to[0]) != 2 || mailer.to[0][0] == "owner@example.com" {
+ t.Fatalf("正式补发必须发给正式收件人,而不是 review 地址:%v", mailer.to[0])
+ }
+ if !strings.Contains(mailer.html[0], "91.20%") || !strings.Contains(mailer.html[0], "cid:weekly-dashboard@knowledge-governance") {
+ t.Fatal("正式补发必须使用冻结的已确认正文和看板图片")
+ }
+ var status, messageID string
+ var revision int64
+ if err := pool.QueryRow(context.Background(), `SELECT status,message_id,draft_revision FROM qa_weekly_email_sends WHERE tenant_key=$1 AND week='2026-W45'`, store.tenant).Scan(&status, &messageID, &revision); err != nil {
+ t.Fatal(err)
+ }
+ if status != "sent" || messageID != sent.MessageID || revision != 16 {
+ t.Fatalf("发送记录必须可读回真实结果:status=%q message_id=%q revision=%d", status, messageID, revision)
+ }
+
+ // 重复点击:幂等,不再投递,并读回同一条 message_id。
+ again, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", now)
+ if err != nil || again.Status != "already_sent" || again.MessageID != sent.MessageID {
+ t.Fatalf("重复补发必须 already_sent 且回同一 message_id:%+v %v", again, err)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("重复补发造成了重复投递:calls=%d", mailer.count())
+ }
+
+ // 版本或正文不匹配:拒绝,不投递。
+ if _, err := store.SendManual(context.Background(), "2026-W45", 15, confirmation.BodySHA256, "fixture-admin", now); !errors.Is(err, ErrNotCurrent) {
+ t.Fatalf("错误版本必须 ErrNotCurrent,got %v", err)
+ }
+ if _, err := store.SendManual(context.Background(), "2026-W45", 16, strings.Repeat("0", 64), "fixture-admin", now); !errors.Is(err, ErrNotCurrent) {
+ t.Fatalf("错误正文 hash 必须 ErrNotCurrent,got %v", err)
+ }
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 17, md+"\n改过的正文\n")
+ if _, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", now); !errors.Is(err, ErrNotCurrent) {
+ t.Fatalf("正文已改必须 ErrNotCurrent,got %v", err)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("被拒绝的补发不得投递:calls=%d", mailer.count())
+ }
+}
+
+// B: the manual window — no future week, and the running week only after its configured slot.
+func TestManualResendWindowRejectsFutureAndTooEarlyWeeks(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, "# 知识库周报 2026-W45\n")
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W46", 1, "# 知识库周报 2026-W46\n")
+ confirmation, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+ // 未来周 W46(即便已确认)明确拒绝。
+ if _, err := store.Confirm(context.Background(), "2026-W46", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ var hash46 string
+ if err := pool.QueryRow(context.Background(), `SELECT body_sha256 FROM qa_weekly_confirmations WHERE tenant_key=$1 AND week='2026-W46'`, store.tenant).Scan(&hash46); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.SendManual(context.Background(), "2026-W46", 1, hash46, "fixture-admin", weekLocal(t, 2026, 11, 8, 23)); !errors.Is(err, ErrFutureWeek) {
+ t.Fatalf("未来周必须 ErrFutureWeek,got %v", err)
+ }
+ // 当前周 W45 在 22:05 尚未到配置时点:拒绝。
+ if _, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", weekLocal(t, 2026, 11, 8, 22)); !errors.Is(err, ErrSlotPending) {
+ t.Fatalf("当前周未到配置时点必须 ErrSlotPending,got %v", err)
+ }
+ // 到点后允许。
+ if result, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", weekLocal(t, 2026, 11, 8, 23)); err != nil || result.Status != "sent" {
+ t.Fatalf("到点后当前周应允许补发:%+v %v", result, err)
+ }
+ // 未确认的周没有 confirmation:拒绝。
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W44", 3, "# 知识库周报 2026-W44\n")
+ if _, err := store.SendManual(context.Background(), "2026-W44", 3, strings.Repeat("a", 64), "fixture-admin", weekLocal(t, 2026, 11, 9, 0)); !errors.Is(err, ErrNotCurrent) {
+ t.Fatalf("未确认周必须 ErrNotCurrent,got %v", err)
+ }
+ if mailer.count() != 1 {
+ t.Fatalf("被拒绝的补发不得投递:calls=%d", mailer.count())
+ }
+}
+
+// C: automatic and manual paths racing on the same week+revision deliver exactly once.
+func TestConcurrentAutoAndManualSendDeliverOnce(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ md := "# 知识库周报 2026-W45\n\n正确率:91.20%\n"
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, md)
+ confirmation, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+ mailer.delay = 150 * time.Millisecond
+ now := weekLocal(t, 2026, 11, 8, 23)
+ var wg sync.WaitGroup
+ states := make([]string, 6)
+ for i := 0; i < 6; i++ {
+ wg.Add(1)
+ go func(index int) {
+ defer wg.Done()
+ if index%2 == 0 {
+ result, callErr := store.SendDue(context.Background(), now)
+ if callErr != nil {
+ states[index] = "error:" + callErr.Error()
+ return
+ }
+ states[index] = result.State
+ return
+ }
+ result, callErr := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", now)
+ if callErr != nil {
+ states[index] = "error:" + callErr.Error()
+ return
+ }
+ states[index] = result.Status
+ }(i)
+ }
+ wg.Wait()
+ if mailer.count() != 1 {
+ t.Fatalf("并发自动/手动必须只投递一次,calls=%d states=%v", mailer.count(), states)
+ }
+ var sentRows int
+ if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM qa_weekly_email_sends WHERE tenant_key=$1 AND week='2026-W45' AND status='sent'`, store.tenant).Scan(&sentRows); err != nil {
+ t.Fatal(err)
+ }
+ if sentRows != 1 {
+ t.Fatalf("sends 表必须有且只有一条 sent:%d states=%v", sentRows, states)
+ }
+}
+
+// C: a send whose outcome is unknown stays unknown — never blindly retried, never marked sent.
+func TestUnknownSendingIsNotResentAndNotMarkedSent(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ md := "# 知识库周报 2026-W45\n\n正确率:91.20%\n"
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, md)
+ confirmation, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+ if _, err := pool.Exec(context.Background(), `INSERT INTO qa_weekly_email_sends(tenant_key,week,draft_revision,body_sha256,status,attempts,created_at,updated_at)
+ VALUES($1,'2026-W45',16,$2,'sending',1,now(),now())`, store.tenant, confirmation.BodySHA256); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", weekLocal(t, 2026, 11, 9, 0)); !errors.Is(err, ErrSendInFlight) {
+ t.Fatalf("结果未知的 sending 必须拒绝重发,got %v", err)
+ }
+ if result, err := store.SendDue(context.Background(), weekLocal(t, 2026, 11, 8, 23)); err != nil || result.State != "in_flight" {
+ t.Fatalf("自动路径必须识别 in_flight,got %+v %v", result, err)
+ }
+ if mailer.count() != 0 {
+ t.Fatalf("未知结果不得盲重发:calls=%d", mailer.count())
+ }
+ var status string
+ if err := pool.QueryRow(context.Background(), `SELECT status FROM qa_weekly_email_sends WHERE tenant_key=$1 AND week='2026-W45'`, store.tenant).Scan(&status); err != nil {
+ t.Fatal(err)
+ }
+ if status != "sending" {
+ t.Fatalf("未知结果不得被改写:status=%q", status)
+ }
+}
+
+// C: a failed attempt is recorded as failed (never sent) and is the only reclaimable state.
+func TestFailedSendIsRecordedThenRetriedOnce(t *testing.T) {
+ store, pool, mailer := weeklyMailFixture(t)
+ md := "# 知识库周报 2026-W45\n\n正确率:91.20%\n"
+ seedWeeklyDraft(t, pool, store.tenant, "2026-W45", 16, md)
+ confirmation, err := store.Confirm(context.Background(), "2026-W45", "fixture-admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enabledWeeklySettings(t, store, 7, 23)
+ mailer.err = errors.New("smtp 连接失败: dial tcp: connection refused")
+ failed, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", weekLocal(t, 2026, 11, 9, 0))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failed.Status != "failed" || failed.MessageID != "" {
+ t.Fatalf("SMTP 失败必须如实记录为 failed:%+v", failed)
+ }
+ var status, lastError string
+ if err := pool.QueryRow(context.Background(), `SELECT status,last_error FROM qa_weekly_email_sends WHERE tenant_key=$1 AND week='2026-W45'`, store.tenant).Scan(&status, &lastError); err != nil {
+ t.Fatal(err)
+ }
+ if status != "failed" || lastError == "" {
+ t.Fatalf("失败记录不得假写 sent:status=%q last_error=%q", status, lastError)
+ }
+ mailer.err = nil
+ retried, err := store.SendManual(context.Background(), "2026-W45", 16, confirmation.BodySHA256, "fixture-admin", weekLocal(t, 2026, 11, 9, 0))
+ if err != nil || retried.Status != "sent" || retried.MessageID == "" {
+ t.Fatalf("failed 记录应允许一次真实重试:%+v %v", retried, err)
+ }
+ if mailer.count() != 2 {
+ t.Fatalf("calls=%d, want 2", mailer.count())
+ }
+}
diff --git a/internal/qawemail/scheduling_integration_test.go b/internal/qawemail/scheduling_integration_test.go
index f387c19..c0e8c21 100644
--- a/internal/qawemail/scheduling_integration_test.go
+++ b/internal/qawemail/scheduling_integration_test.go
@@ -105,9 +105,13 @@ func TestScheduledSendRequiresConfirmationAndSendsExactlyOnce(t *testing.T) {
t.Fatal("the sealed password must be unsealed for the SMTP call")
}
again, err := store.SendDue(ctx, slot)
- if err != nil || again.State != "nothing_confirmed" {
+ // 同一时段第二次触发必须幂等:已 sent 就不再投递,也不谎报成“没有可发的周报”。
+ if err != nil || again.State != "already_sent" {
t.Fatalf("a second attempt in the same slot must not resend, got %+v %v", again, err)
}
+ if again.MessageID == "" {
+ t.Fatal("already_sent 必须带回第一次真实发送的 message_id")
+ }
if len(mailer.sent) != 1 {
t.Fatal("duplicate send detected")
}
diff --git a/internal/qawemail/store.go b/internal/qawemail/store.go
index 3ec5d70..dc0cc93 100644
--- a/internal/qawemail/store.go
+++ b/internal/qawemail/store.go
@@ -29,6 +29,13 @@ var (
ErrInvalid = errors.New("weekly mail invalid input")
ErrConflict = errors.New("weekly mail settings changed")
ErrNoPassword = errors.New("weekly mail password missing")
+ // Manual resend has its own refusals: an operator must be told which rule failed instead of
+ // seeing a generic SMTP error for something SMTP never saw.
+ ErrNotCurrent = errors.New("weekly mail confirmation is not current")
+ ErrFutureWeek = errors.New("weekly mail week is in the future")
+ ErrSlotPending = errors.New("weekly mail slot has not arrived for the running week")
+ ErrNotConfigured = errors.New("weekly mail transport is not configured")
+ ErrSendInFlight = errors.New("weekly mail send outcome is unknown")
)
// Settings never carries the SMTP password; HasPassword only says whether one is stored.
@@ -81,6 +88,7 @@ type Send struct {
Status string `json:"status"`
Attempts int `json:"attempts"`
LastError string `json:"last_error"`
+ MessageID string `json:"message_id"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -414,7 +422,7 @@ func (s *Store) Sends(ctx context.Context, week string) ([]Send, error) {
out := []Send{}
for rows.Next() {
var item Send
- if err := rows.Scan(&item.Week, &item.Revision, &item.Status, &item.Attempts, &item.LastError, &item.UpdatedAt); err != nil {
+ if err := rows.Scan(&item.Week, &item.Revision, &item.Status, &item.Attempts, &item.LastError, &item.MessageID, &item.UpdatedAt); err != nil {
return nil, ErrUnavailable
}
out = append(out, item)
@@ -423,10 +431,11 @@ func (s *Store) Sends(ctx context.Context, week string) ([]Send, error) {
}
type DueResult struct {
- State string `json:"state"`
- Week string `json:"week,omitempty"`
- SentAt time.Time `json:"sent_at,omitempty"`
- Reason string `json:"reason,omitempty"`
+ State string `json:"state"`
+ Week string `json:"week,omitempty"`
+ MessageID string `json:"message_id,omitempty"`
+ SentAt time.Time `json:"sent_at,omitempty"`
+ Reason string `json:"reason,omitempty"`
}
// weekStart resolves the Beijing Monday of an ISO week label; reports are due after that week closes.
@@ -445,9 +454,11 @@ func weekStart(week string) (time.Time, error) {
return start, nil
}
+var cstZone = time.FixedZone("CST", 8*3600)
+
// slotMatches is the single definition of "the configured weekly slot is now".
func slotMatches(settings Settings, now time.Time) bool {
- local := now.In(time.FixedZone("CST", 8*3600))
+ local := now.In(cstZone)
weekday := int(local.Weekday())
if weekday == 0 {
weekday = 7
@@ -455,6 +466,25 @@ func slotMatches(settings Settings, now time.Time) bool {
return weekday == settings.Weekday && local.Hour() == settings.Hour
}
+// slotWeek names the report week the configured slot stands for.
+//
+// A slot on the week's last day (周日) reports the week that ends that same day; on every other
+// weekday the week has already ended, which is what "周一 09:00 发上一个完整周" has always meant.
+// Deriving the week from the slot — instead of asking reportClosed whether the week has ended —
+// is what the 周日23点 configuration needs: that slot sits inside its own week, so waiting for the
+// next Monday 00:00 would always miss the window it was configured for.
+func slotWeek(settings Settings, now time.Time) (string, bool) {
+ if !slotMatches(settings, now) {
+ return "", false
+ }
+ day := now.In(cstZone)
+ if settings.Weekday != 7 {
+ day = day.AddDate(0, 0, -7)
+ }
+ year, number := day.ISOWeek()
+ return fmt.Sprintf("%04d-W%02d", year, number), true
+}
+
// reportClosed reports whether the ISO week is over and its report may go out.
func reportClosed(week string, now time.Time) bool {
start, err := weekStart(week)
@@ -464,7 +494,7 @@ func reportClosed(week string, now time.Time) bool {
return !now.Before(start.AddDate(0, 0, 7))
}
-// SendDue mails only a confirmed, still-current, already-closed week at the configured slot.
+// SendDue mails only the confirmed, still-current week the configured slot stands for.
// Every skip path returns a reason instead of pretending a message went out.
func (s *Store) SendDue(ctx context.Context, now time.Time) (DueResult, error) {
if !s.valid() {
@@ -477,49 +507,31 @@ func (s *Store) SendDue(ctx context.Context, now time.Time) (DueResult, error) {
if !settings.Enabled {
return DueResult{State: "disabled", Reason: "未启用自动发送"}, nil
}
- if !slotMatches(settings, now) {
+ week, ok := slotWeek(settings, now)
+ if !ok {
return DueResult{State: "not_due", Reason: "未到设定发送时段"}, nil
}
- rows, err := s.pool.Query(ctx, `SELECT c.week,c.draft_revision,c.body_sha256,d.revision,d.markdown
+ // Only the week this slot stands for may go out automatically. Replaying every older
+ // confirmed week would turn one missed run into an unbounded backfill.
+ var revision, draftRevision int64
+ var hash, markdown string
+ err = s.pool.QueryRow(ctx, `SELECT c.draft_revision,c.body_sha256,d.revision,d.markdown
FROM qa_weekly_confirmations c JOIN qa_ops_weekly_drafts d ON d.tenant_key=c.tenant_key AND d.week=c.week
- WHERE c.tenant_key=$1 ORDER BY c.week DESC`, s.tenant)
+ WHERE c.tenant_key=$1 AND c.week=$2`, s.tenant, week).Scan(&revision, &hash, &draftRevision, &markdown)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return DueResult{State: "nothing_confirmed", Week: week, Reason: "该周没有已确认的周报"}, nil
+ }
if err != nil {
return DueResult{}, ErrUnavailable
}
- type candidate struct {
- week string
- revision int64
- hash string
- markdown string
+ if draftRevision != revision || bodyHash(markdown) != hash || strings.TrimSpace(markdown) == "" {
+ return DueResult{State: "nothing_confirmed", Week: week, Reason: "已确认的正文或版本已变化,需重新确认"}, nil
}
- candidates := []candidate{}
- for rows.Next() {
- var c candidate
- var draftRevision int64
- if err := rows.Scan(&c.week, &c.revision, &c.hash, &draftRevision, &c.markdown); err != nil {
- rows.Close()
- return DueResult{}, ErrUnavailable
- }
- if draftRevision != c.revision || bodyHash(c.markdown) != c.hash {
- continue
- }
- candidates = append(candidates, c)
+ delivered, err := s.deliver(ctx, settings, week, revision, markdown, now, fmt.Sprintf("知识库周报 %s", week))
+ if err != nil {
+ return DueResult{}, err
}
- rows.Close()
- for _, c := range candidates {
- if !reportClosed(c.week, now) {
- continue
- }
- var sent bool
- if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM qa_weekly_email_sends WHERE tenant_key=$1 AND week=$2 AND draft_revision=$3 AND status='sent')`, s.tenant, c.week, c.revision).Scan(&sent); err != nil {
- return DueResult{}, ErrUnavailable
- }
- if sent {
- continue
- }
- return s.send(ctx, settings, c.week, c.revision, c.markdown, now)
- }
- return DueResult{State: "nothing_confirmed", Reason: "没有已确认且已结束的周报"}, nil
+ return DueResult{State: delivered.state, Week: week, MessageID: delivered.messageID, SentAt: delivered.sentAt, Reason: delivered.reason}, nil
}
// SaveDashboardImage stores the dashboard PNG that belongs to the currently confirmed revision,
@@ -597,25 +609,42 @@ func (s *Store) TestSend(ctx context.Context, week string) (string, error) {
Message{To: target, Cc: nil, From: settings.From, Subject: fmt.Sprintf("知识库周报 %s(测试发送)", week), HTML: html, Text: text, Images: images})
}
-func (s *Store) send(ctx context.Context, settings Settings, week string, revision int64, markdown string, now time.Time) (DueResult, error) {
+// delivery is what one attempt at a single (week, revision) proved: a state, never an assumption.
+type delivery struct {
+ state string
+ messageID string
+ reason string
+ sentAt time.Time
+}
+
+// deliver is the only place a weekly report is rendered, claimed and handed to the mailer, so the
+// automatic slot and the manual resend can never drift apart.
+//
+// The claim is also the concurrency control: only a brand-new row or a 'failed' row may be taken.
+// A 'sent' row is idempotent, and a 'sending' row means the previous outcome is unknown — resending
+// it could deliver the report twice, so it is reported instead of retried. (SMTP offers no exactly
+// -once primitive; this is the strongest honest guarantee.)
+func (s *Store) deliver(ctx context.Context, settings Settings, week string, revision int64, markdown string, now time.Time, subject string) (delivery, error) {
password, err := s.password(ctx, settings.Revision)
if err != nil {
- return DueResult{State: "blocked", Week: week, Reason: "SMTP 密码未配置或无法解密"}, nil
+ return delivery{state: "blocked", reason: "SMTP 密码未配置或无法解密"}, nil
}
html, text := renderReport(markdown)
images, err := s.inlineImages(ctx, week, &html)
if err != nil {
- return DueResult{}, err
+ return delivery{}, err
}
- subject := fmt.Sprintf("知识库周报 %s", week)
var sendID string
err = s.pool.QueryRow(ctx, `INSERT INTO qa_weekly_email_sends(tenant_key,week,draft_revision,body_sha256,status,attempts,created_at,updated_at)
VALUES($1,$2,$3,$4,'sending',1,now(),now())
ON CONFLICT (tenant_key,week,draft_revision) DO UPDATE SET status='sending',attempts=qa_weekly_email_sends.attempts+1,updated_at=now()
- WHERE qa_weekly_email_sends.status<>'sent'
+ WHERE qa_weekly_email_sends.status='failed'
RETURNING id::text`, s.tenant, week, revision, bodyHash(markdown)).Scan(&sendID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return s.existingDelivery(ctx, week, revision)
+ }
if err != nil {
- return DueResult{}, ErrUnavailable
+ return delivery{}, ErrUnavailable
}
messageID, sendErr := s.mailer.Send(ctx, SMTPConfig{Host: settings.Host, Port: settings.Port, Username: settings.Username, Password: password},
Message{To: settings.Recipients, Cc: settings.Cc, From: settings.From, Subject: subject, HTML: html, Text: text, Images: images})
@@ -625,12 +654,158 @@ func (s *Store) send(ctx context.Context, settings Settings, week string, revisi
reason = reason[:400]
}
_, _ = s.pool.Exec(ctx, `UPDATE qa_weekly_email_sends SET status='failed',last_error=$2,updated_at=now() WHERE id=$1`, sendID, reason)
- return DueResult{State: "failed", Week: week, Reason: "发送失败,稍后重试"}, nil
+ return delivery{state: "failed", reason: "发送失败:" + reason}, nil
}
if _, err = s.pool.Exec(ctx, `UPDATE qa_weekly_email_sends SET status='sent',message_id=$2,last_error='',updated_at=now() WHERE id=$1`, sendID, messageID); err != nil {
- return DueResult{}, ErrUnavailable
+ return delivery{}, ErrUnavailable
}
- return DueResult{State: "sent", Week: week, SentAt: now}, nil
+ return delivery{state: "sent", messageID: messageID, sentAt: now}, nil
+}
+
+// existingDelivery explains why this attempt could not claim the row, instead of guessing.
+func (s *Store) existingDelivery(ctx context.Context, week string, revision int64) (delivery, error) {
+ var status, messageID, lastError string
+ var updatedAt time.Time
+ err := s.pool.QueryRow(ctx, `SELECT status,message_id,last_error,updated_at FROM qa_weekly_email_sends
+ WHERE tenant_key=$1 AND week=$2 AND draft_revision=$3`, s.tenant, week, revision).Scan(&status, &messageID, &lastError, &updatedAt)
+ if err != nil {
+ return delivery{}, ErrUnavailable
+ }
+ switch status {
+ case "sent":
+ return delivery{state: "already_sent", messageID: messageID, reason: "该周该版本已发送,未重复投递", sentAt: updatedAt}, nil
+ case "sending":
+ return delivery{state: "in_flight", reason: "上次发送结果未知(记录停在 sending),为避免重复投递未重发;请先核对已发出的邮件"}, nil
+ default:
+ return delivery{state: "in_flight", reason: "发送记录状态与本次认领冲突(" + status + "),未重复投递"}, nil
+ }
+}
+
+type ManualResult struct {
+ Week string `json:"week"`
+ Revision int64 `json:"draft_revision"`
+ Status string `json:"status"`
+ MessageID string `json:"message_id,omitempty"`
+ Recipients int `json:"recipients"`
+ Cc int `json:"cc_recipients"`
+ Reason string `json:"reason,omitempty"`
+ SentAt time.Time `json:"sent_at,omitempty"`
+}
+
+// SendPreview is what the confirmation dialog must show before an operator commits: the exact
+// week/revision and the real distribution counts the send would use.
+type SendPreview struct {
+ Week string `json:"week"`
+ Revision int64 `json:"draft_revision"`
+ BodySHA256 string `json:"body_sha256"`
+ Recipients int `json:"recipients"`
+ Cc int `json:"cc_recipients"`
+ Status string `json:"status"`
+ MessageID string `json:"message_id,omitempty"`
+ SentAt time.Time `json:"sent_at,omitempty"`
+}
+
+// manualWindow decides whether a week may be sent by hand right now: a week that has ended is
+// always fair game; the running week only once its own configured slot has arrived; a future week
+// never.
+func manualWindow(week string, settings Settings, now time.Time) error {
+ start, err := weekStart(week)
+ if err != nil {
+ return ErrInvalid
+ }
+ local := now.In(cstZone)
+ if start.After(local) {
+ return ErrFutureWeek
+ }
+ if !local.Before(start.AddDate(0, 0, 7)) {
+ return nil
+ }
+ slot := start.AddDate(0, 0, settings.Weekday-1).Add(time.Duration(settings.Hour) * time.Hour)
+ if local.Before(slot) {
+ return ErrSlotPending
+ }
+ return nil
+}
+
+// prepareManual binds a manual request to the confirmed revision and body that are live right now.
+func (s *Store) prepareManual(ctx context.Context, week string, revision int64, hash string, now time.Time) (Settings, string, error) {
+ settings, err := s.ReadSettings(ctx)
+ if err != nil {
+ return settings, "", err
+ }
+ if settings.Host == "" || settings.From == "" || len(settings.Recipients) == 0 {
+ return settings, "", ErrNotConfigured
+ }
+ if err := manualWindow(week, settings, now); err != nil {
+ return settings, "", err
+ }
+ var confirmedRevision, draftRevision int64
+ var confirmedHash, markdown string
+ err = s.pool.QueryRow(ctx, `SELECT c.draft_revision,c.body_sha256,d.revision,d.markdown
+ FROM qa_weekly_confirmations c JOIN qa_ops_weekly_drafts d ON d.tenant_key=c.tenant_key AND d.week=c.week
+ WHERE c.tenant_key=$1 AND c.week=$2`, s.tenant, week).Scan(&confirmedRevision, &confirmedHash, &draftRevision, &markdown)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return settings, "", ErrNotCurrent
+ }
+ if err != nil {
+ return settings, "", ErrUnavailable
+ }
+ if confirmedRevision != revision || confirmedHash != hash || draftRevision != revision ||
+ strings.TrimSpace(markdown) == "" || bodyHash(markdown) != hash {
+ return settings, "", ErrNotCurrent
+ }
+ return settings, markdown, nil
+}
+
+// PreviewSend validates a manual resend without sending or writing anything.
+func (s *Store) PreviewSend(ctx context.Context, week string, revision int64, hash string, now time.Time) (SendPreview, error) {
+ if !s.valid() || revision <= 0 || len(strings.TrimSpace(hash)) != 64 {
+ return SendPreview{}, ErrInvalid
+ }
+ settings, _, err := s.prepareManual(ctx, week, revision, hash, now)
+ if err != nil {
+ return SendPreview{}, err
+ }
+ out := SendPreview{Week: week, Revision: revision, BodySHA256: hash, Recipients: len(settings.Recipients), Cc: len(settings.Cc), Status: "ready"}
+ var status, messageID string
+ var updatedAt time.Time
+ err = s.pool.QueryRow(ctx, `SELECT status,message_id,updated_at FROM qa_weekly_email_sends
+ WHERE tenant_key=$1 AND week=$2 AND draft_revision=$3`, s.tenant, week, revision).Scan(&status, &messageID, &updatedAt)
+ switch {
+ case errors.Is(err, pgx.ErrNoRows):
+ case err != nil:
+ return SendPreview{}, ErrUnavailable
+ case status == "sent":
+ out.Status, out.MessageID, out.SentAt = "already_sent", messageID, updatedAt
+ case status == "sending":
+ out.Status = "in_flight"
+ }
+ return out, nil
+}
+
+// SendManual is the explicit operator path for a missed or refused automatic run. It reuses the
+// automatic render/claim/deliver path, so body, chart, To/Cc and idempotency are identical.
+func (s *Store) SendManual(ctx context.Context, week string, revision int64, hash, actor string, now time.Time) (ManualResult, error) {
+ if !s.valid() || strings.TrimSpace(actor) == "" || revision <= 0 || len(strings.TrimSpace(hash)) != 64 {
+ return ManualResult{}, ErrInvalid
+ }
+ settings, markdown, err := s.prepareManual(ctx, week, revision, hash, now)
+ if err != nil {
+ return ManualResult{}, err
+ }
+ delivered, err := s.deliver(ctx, settings, week, revision, markdown, now, fmt.Sprintf("知识库周报 %s", week))
+ if err != nil {
+ return ManualResult{}, err
+ }
+ out := ManualResult{Week: week, Revision: revision, Status: delivered.state, MessageID: delivered.messageID,
+ Recipients: len(settings.Recipients), Cc: len(settings.Cc), Reason: delivered.reason, SentAt: delivered.sentAt}
+ switch delivered.state {
+ case "blocked":
+ return out, ErrNoPassword
+ case "in_flight":
+ return out, ErrSendInFlight
+ }
+ return out, nil
}
const dashboardImageCID = "weekly-dashboard@knowledge-governance"
diff --git a/internal/qawemail/store_test.go b/internal/qawemail/store_test.go
index 8e3950b..a833f0a 100644
--- a/internal/qawemail/store_test.go
+++ b/internal/qawemail/store_test.go
@@ -35,6 +35,35 @@ func TestReportClosedOnlyAfterTheISOWeekEnds(t *testing.T) {
}
}
+// 时段自身决定要发哪一周:周日时段发当天结束的那一周,其它工作日发已经结束的上一周。
+// 旧实现只在“周已结束(下周一00:00)”时发,而周日23点这个时段在周内 ⇒ 当周永远发不出去。
+func TestSlotWeekDerivesTheReportWeekFromTheSlot(t *testing.T) {
+ cst := time.FixedZone("CST", 8*3600)
+ sunday := Settings{Weekday: 7, Hour: 23}
+ week, ok := slotWeek(sunday, time.Date(2026, 11, 8, 23, 5, 0, 0, cst))
+ if !ok || week != "2026-W45" {
+ t.Fatalf("周日23点必须对应当周 2026-W45,got %q %v", week, ok)
+ }
+ if _, ok := slotWeek(sunday, time.Date(2026, 11, 8, 22, 5, 0, 0, cst)); ok {
+ t.Fatal("到点前不得认为时段已到")
+ }
+ if _, ok := slotWeek(sunday, time.Date(2026, 11, 7, 23, 5, 0, 0, cst)); ok {
+ t.Fatal("周六23点不是配置的周日时段")
+ }
+ // 周一09点保持旧语义:发上一完整周。
+ monday := Settings{Weekday: 1, Hour: 9}
+ week, ok = slotWeek(monday, time.Date(2026, 11, 9, 9, 5, 0, 0, cst))
+ if !ok || week != "2026-W45" {
+ t.Fatalf("周一09点必须对应上一完整周 2026-W45,got %q %v", week, ok)
+ }
+ // 周三09点同理:该周尚未结束,不能提前发。
+ wednesday := Settings{Weekday: 3, Hour: 9}
+ week, ok = slotWeek(wednesday, time.Date(2026, 11, 11, 9, 5, 0, 0, cst))
+ if !ok || week != "2026-W45" {
+ t.Fatalf("周三09点必须仍指向 2026-W45,got %q %v", week, ok)
+ }
+}
+
func TestNormalizeRefusesEnabledSettingsWithoutTransportAndBadRecipients(t *testing.T) {
if _, err := normalize(SettingsInput{Host: "", From: "", Recipients: nil, Enabled: true}); err != ErrInvalid {
t.Fatalf("enabled without host/from/recipients must be invalid, got %v", err)
diff --git a/internal/qawemail/sunday_slot_test.go b/internal/qawemail/sunday_slot_test.go
new file mode 100644
index 0000000..08a3d1a
--- /dev/null
+++ b/internal/qawemail/sunday_slot_test.go
@@ -0,0 +1,79 @@
+package qawemail
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ postgresstore "knowledge-governance/internal/platform/postgres"
+)
+
+type slotMailer struct {
+ sent []Message
+}
+
+func (m *slotMailer) Send(_ context.Context, _ SMTPConfig, msg Message) (string, error) {
+ m.sent = append(m.sent, msg)
+ return "", nil
+}
+
+// 现场形状:send_weekday=7 / send_hour=23(周日23点),正文已确认。
+// 旧实现的 reportClosed 要求「周已结束 = 下周一00:00」,而时段只存在于周日23点这一小时内,
+// 于是当周周报在它自己的发送窗口里永远不满足条件,只能等周一——那时时段已过 ⇒ 永不发送。
+// 判据:周日23点这个时段必须发出当周(2026-W45),而不是等到周一。
+func TestSundaySlotMailsItsOwnWeekBeforeMonday(t *testing.T) {
+ dsn := os.Getenv("KG_WEEKLY_MAIL_TEST_DSN")
+ if dsn == "" {
+ t.Skip("显式一次性周报邮件数据库未提供(KG_WEEKLY_MAIL_TEST_DSN):跳过不等于通过")
+ }
+ if !strings.Contains(dsn, "/kg_weekly_mail_test_") || strings.Contains(dsn, "/governance?") {
+ t.Fatal("拒绝非一次性周报测试库")
+ }
+ ctx := context.Background()
+ pool, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ t.Fatal("测试库连接参数无效(详情省略)")
+ }
+ defer pool.Close()
+ if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ mailer := &slotMailer{}
+ store := NewStore(pool, "fixture-sunday-slot", os.Getenv("KG_WEEKLY_MAIL_TEST_KEY"), mailer)
+ for _, statement := range []string{
+ `DELETE FROM qa_weekly_email_sends WHERE tenant_key=$1`,
+ `DELETE FROM qa_weekly_confirmations WHERE tenant_key=$1`,
+ `DELETE FROM qa_weekly_email_settings WHERE tenant_key=$1`,
+ `DELETE FROM qa_ops_weekly_drafts WHERE tenant_key=$1`,
+ } {
+ if _, err := pool.Exec(ctx, statement, store.tenant); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO qa_ops_weekly_drafts(tenant_key,week,revision,markdown,seed,updated_by,updated_at)
+ VALUES($1,'2026-W45',16,$2,'{}'::jsonb,'fixture-admin',now())`, store.tenant,
+ "# 知识库周报 2026-W45\n\n## 二、核心指标\n\n正确率:91.20%\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.SaveSettings(ctx, "fixture-admin", SettingsInput{
+ BaseRevision: 0, Host: "localhost", Port: 465, From: "kb@example.com", Password: "app-password",
+ Recipients: []string{"lead@example.com"}, Weekday: 7, Hour: 23, Enabled: true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Confirm(ctx, "2026-W45", "fixture-admin"); err != nil {
+ t.Fatal(err)
+ }
+ sunday := time.Date(2026, 11, 8, 23, 5, 0, 0, time.FixedZone("CST", 8*3600))
+ result, err := store.SendDue(ctx, sunday)
+ if err != nil || result.State != "sent" || result.Week != "2026-W45" {
+ t.Fatalf("周日23点必须发出当周 2026-W45(旧实现在此只报 nothing_confirmed),got %+v %v", result, err)
+ }
+ if len(mailer.sent) != 1 {
+ t.Fatalf("calls=%d, want 1", len(mailer.sent))
+ }
+}
diff --git a/scripts/test-scope.json b/scripts/test-scope.json
index 53f50ee..7ba91a5 100644
--- a/scripts/test-scope.json
+++ b/scripts/test-scope.json
@@ -408,6 +408,51 @@
"test": "TestScheduledSendRequiresConfirmationAndSendsExactlyOnce",
"reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
},
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestSundaySlotMailsItsOwnWeekBeforeMonday",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库 real PG 上跑通(旧实现真实复现 nothing_confirmed)"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestAutoSendAtSundaySlotMailsTheWeekThatEndsThatDay",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestAutoSendRefusesUnconfirmedChangedAndFutureWeeks",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestAutoSendOnMondayStillMailsThePreviousCompleteWeek",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestManualResendSendsFrozenRevisionOnceAndReadsBackMessageID",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestManualResendWindowRejectsFutureAndTooEarlyWeeks",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestConcurrentAutoAndManualSendDeliverOnce",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实验证并发只投递一次"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestUnknownSendingIsNotResentAndNotMarkedSent",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
+ {
+ "package": "knowledge-governance/internal/qawemail",
+ "test": "TestFailedSendIsRecordedThenRetriedOnce",
+ "reason": "需要一个一次性 PostgreSQL 与测试主密钥(KG_WEEKLY_MAIL_TEST_DSN/KG_WEEKLY_MAIL_TEST_KEY),CI 不提供;本批已在隔离库真实跑通并通过"
+ },
{
"package": "knowledge-governance/internal/releaseadapter/v071",
"test": "TestLibraryChunkSequenceIsolated",
diff --git a/web/src/QAWeeklyMail.tsx b/web/src/QAWeeklyMail.tsx
index 2078505..15e366d 100644
--- a/web/src/QAWeeklyMail.tsx
+++ b/web/src/QAWeeklyMail.tsx
@@ -9,12 +9,14 @@ import {
fetchWeeklyMailConfirmation,
fetchWeeklyMailSends,
fetchWeeklyMailSettings,
+ previewWeeklyMailSend,
saveWeeklyMailSettings,
+ sendWeeklyMailFormal,
sendWeeklyMailTest,
uploadWeeklyMailImage,
weeklyMailWeekdayLabels,
} from './qaWeeklyMail'
-import type { WeeklyMailConfirmation, WeeklyMailSend, WeeklyMailSettings } from './qaWeeklyMail'
+import type { WeeklyMailConfirmation, WeeklyMailSend, WeeklyMailSendPreview, WeeklyMailSettings } from './qaWeeklyMail'
import './qaWeeklyMail.css'
const stamp = (value: string) => (value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—')
@@ -32,6 +34,9 @@ export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
const [testing, setTesting] = useState(false)
const [capture, setCapture] = useState(false)
const [attaching, setAttaching] = useState(false)
+ const [preparingResend, setPreparingResend] = useState(false)
+ const [resendConfirm, setResendConfirm] = useState(null)
+ const [resending, setResending] = useState(false)
const reload = useCallback(async () => {
setError('')
@@ -91,6 +96,44 @@ export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
setTesting(false)
}
}
+ // 正式补发不是测试发送:先向服务端核对当前已确认版本与真实收件人数,再由操作者确认。
+ const prepareResend = async () => {
+ if (preparingResend || !confirmation?.current) return
+ setPreparingResend(true)
+ setError('')
+ setNotice('')
+ try {
+ setResendConfirm(await previewWeeklyMailSend(week, confirmation.draft_revision, confirmation.body_sha256))
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : '补发前核对失败')
+ } finally {
+ setPreparingResend(false)
+ }
+ }
+ const confirmResend = async () => {
+ if (resending || !resendConfirm) return
+ setResending(true)
+ setError('')
+ setNotice('')
+ try {
+ const result = await sendWeeklyMailFormal(
+ resendConfirm.week,
+ resendConfirm.draft_revision,
+ resendConfirm.body_sha256
+ )
+ setNotice(
+ result.status === 'already_sent'
+ ? `该周第${result.draft_revision}版此前已发送(${result.message_id}),本次未重复投递`
+ : `已正式补发 ${result.week} 第${result.draft_revision}版给 ${result.recipients} 位收件人、${result.cc_recipients} 位抄送(${result.message_id})`
+ )
+ setResendConfirm(null)
+ setSends(await fetchWeeklyMailSends(week))
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : '正式补发失败')
+ } finally {
+ setResending(false)
+ }
+ }
const attachDashboard = () => {
if (attaching || !confirmation) return
setError('')
@@ -142,6 +185,15 @@ export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
+ {confirmation?.current ? (
+
+ ) : null}
{confirmation?.has_image && !stale ? (
) : null}
{notice ? {notice}
: null}
+ {resendConfirm ? (
+
+
+ 将正式补发《知识库周报 {resendConfirm.week}》第{resendConfirm.draft_revision}版给{' '}
+ {resendConfirm.recipients} 位收件人、{resendConfirm.cc_recipients} 位抄送(正式名单,不是测试邮箱),
+ 并写入正式发送记录。
+ {resendConfirm.status === 'already_sent' ? '该版本已有发送记录,确认后只读回结果、不会重复投递。' : ''}
+ {resendConfirm.status === 'in_flight'
+ ? '该版本上次发送结果未知,服务端会拒绝重发,请先核对已发出的邮件。'
+ : ''}
+
+
+
+
+
+
+ ) : null}
{settings ? (
{settings.enabled ? '自动发送已启用' : '自动发送未启用'} ·{' '}
@@ -187,6 +260,7 @@ export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
{send.week} · 第{send.draft_revision}版 ·{' '}
{{ sending: '发送中', sent: '已发送', failed: '发送失败' }[send.status]} · {stamp(send.updated_at)}
{send.last_error ? ` · ${send.last_error}` : ''}
+ {send.message_id ? ` · ${send.message_id}` : ''}
))}
diff --git a/web/src/qaWeeklyMail.css b/web/src/qaWeeklyMail.css
index 31ae2b9..d070f7c 100644
--- a/web/src/qaWeeklyMail.css
+++ b/web/src/qaWeeklyMail.css
@@ -50,6 +50,22 @@
.qa-weekly-mail-sends li[data-status='sent'] {
color: #2d704d;
}
+/* 正式补发是不可撤回的投递:与普通提示区分开,避免误点。 */
+.qa-weekly-mail-confirm {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid #f0c36d;
+ border-radius: 8px;
+ background: #fffaeb;
+ font-size: 13px;
+ color: #5b3d00;
+}
+.qa-weekly-mail-confirm p {
+ margin: 0;
+ line-height: 1.6;
+}
.qa-weekly-mail-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
diff --git a/web/src/qaWeeklyMail.test.tsx b/web/src/qaWeeklyMail.test.tsx
index 9a875de..c4d6493 100644
--- a/web/src/qaWeeklyMail.test.tsx
+++ b/web/src/qaWeeklyMail.test.tsx
@@ -1,6 +1,12 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
-import { parseWeeklyMailConfirmation, parseWeeklyMailSends, parseWeeklyMailSettings } from './qaWeeklyMail'
+import {
+ parseWeeklyMailConfirmation,
+ parseWeeklyMailFormalResult,
+ parseWeeklyMailSendPreview,
+ parseWeeklyMailSends,
+ parseWeeklyMailSettings,
+} from './qaWeeklyMail'
import { QAWeeklyMail } from './QAWeeklyMail'
const settings = {
@@ -72,5 +78,72 @@ it('shows the confirmation gate before any automatic send controls', () => {
expect(html).toContain('未确认')
expect(html).toContain('确认本周周报')
expect(html).not.toContain('立即发送')
+ expect(html).not.toContain('正式补发')
expect(html).not.toContain('发送设置')
})
+
+describe('weekly mail formal resend contract', () => {
+ it('shows the real distribution and revision in the pre-send preview', () => {
+ const preview = parseWeeklyMailSendPreview(
+ {
+ week: '2026-W45',
+ draft_revision: 16,
+ body_sha256: 'a'.repeat(64),
+ recipients: 119,
+ cc_recipients: 8,
+ status: 'ready',
+ },
+ '2026-W45'
+ )
+ expect(preview).toMatchObject({ recipients: 119, cc_recipients: 8, draft_revision: 16, status: 'ready' })
+ expect(() => parseWeeklyMailSendPreview({ ...preview, status: 'queued' }, '2026-W45')).toThrow()
+ expect(() => parseWeeklyMailSendPreview({ ...preview, week: '2026-W44' }, '2026-W45')).toThrow()
+ })
+ it('never reports success without a real message id', () => {
+ const sent = parseWeeklyMailFormalResult(
+ {
+ week: '2026-W45',
+ draft_revision: 16,
+ status: 'sent',
+ message_id: '',
+ recipients: 119,
+ cc_recipients: 8,
+ },
+ '2026-W45'
+ )
+ expect(sent.message_id).toBe('')
+ expect(() =>
+ parseWeeklyMailFormalResult(
+ { week: '2026-W45', draft_revision: 16, status: 'sent', message_id: '', recipients: 119, cc_recipients: 8 },
+ '2026-W45'
+ )
+ ).toThrow()
+ expect(() =>
+ parseWeeklyMailFormalResult(
+ {
+ week: '2026-W45',
+ draft_revision: 16,
+ status: 'failed',
+ message_id: '',
+ recipients: 1,
+ cc_recipients: 0,
+ },
+ '2026-W45'
+ )
+ ).toThrow()
+ })
+ it('keeps the read-back message id on the send history', () => {
+ const [send] = parseWeeklyMailSends([
+ {
+ week: '2026-W45',
+ draft_revision: 16,
+ status: 'sent',
+ attempts: 1,
+ last_error: '',
+ message_id: '',
+ updated_at: '2026-11-09T00:32:00+08:00',
+ },
+ ])
+ expect(send.message_id).toBe('')
+ })
+})
diff --git a/web/src/qaWeeklyMail.ts b/web/src/qaWeeklyMail.ts
index 73d2247..6e8c9ce 100644
--- a/web/src/qaWeeklyMail.ts
+++ b/web/src/qaWeeklyMail.ts
@@ -33,9 +33,30 @@ export interface WeeklyMailSend {
status: 'sending' | 'sent' | 'failed'
attempts: number
last_error: string
+ message_id: string
updated_at: string
}
+// 正式补发前的核对结果:确认框显示真实收件人数,而不是测试邮箱。
+export interface WeeklyMailSendPreview {
+ week: string
+ draft_revision: number
+ body_sha256: string
+ recipients: number
+ cc_recipients: number
+ status: 'ready' | 'already_sent' | 'in_flight'
+ message_id: string
+}
+
+export interface WeeklyMailFormalResult {
+ week: string
+ draft_revision: number
+ status: 'sent' | 'already_sent'
+ message_id: string
+ recipients: number
+ cc_recipients: number
+}
+
const bad = (): never => {
throw new Error('周报邮件响应无效,未显示或未标记为已保存')
}
@@ -99,11 +120,44 @@ export function parseWeeklyMailSends(value: unknown): WeeklyMailSend[] {
status: v.status as WeeklyMailSend['status'],
attempts: num(v.attempts),
last_error: str(v.last_error),
+ message_id: typeof v.message_id === 'string' ? v.message_id : '',
updated_at: str(v.updated_at),
}
})
}
+export function parseWeeklyMailSendPreview(value: unknown, week: string): WeeklyMailSendPreview {
+ const v = obj(value)
+ if (v.week !== week) return bad()
+ if (!['ready', 'already_sent', 'in_flight'].includes(str(v.status))) return bad()
+ return {
+ week,
+ draft_revision: num(v.draft_revision),
+ body_sha256: str(v.body_sha256),
+ recipients: num(v.recipients),
+ cc_recipients: num(v.cc_recipients),
+ status: v.status as WeeklyMailSendPreview['status'],
+ message_id: typeof v.message_id === 'string' ? v.message_id : '',
+ }
+}
+
+export function parseWeeklyMailFormalResult(value: unknown, week: string): WeeklyMailFormalResult {
+ const v = obj(value)
+ if (v.week !== week) return bad()
+ if (!['sent', 'already_sent'].includes(str(v.status))) return bad()
+ // 没有 message_id 的“成功”是假成功:必须当作无效响应。
+ const messageID = str(v.message_id)
+ if (!messageID) return bad()
+ return {
+ week,
+ draft_revision: num(v.draft_revision),
+ status: v.status as WeeklyMailFormalResult['status'],
+ message_id: messageID,
+ recipients: num(v.recipients),
+ cc_recipients: num(v.cc_recipients),
+ }
+}
+
const endpoint = (path: string, week?: string) =>
`/api/v1/qa-ops/master/weekly-mail/${path}${week ? '/' + encodeURIComponent(week) : ''}`
const headers = { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-weekly-mail' }
@@ -173,6 +227,29 @@ export async function sendWeeklyMailTest(week: string): Promise {
return payload.message_id
}
+export async function previewWeeklyMailSend(
+ week: string,
+ revision: number,
+ bodySHA256: string
+): Promise {
+ const query = new URLSearchParams({ revision: String(revision), body_sha256: bodySHA256 }).toString()
+ return parseWeeklyMailSendPreview(await getPayload(endpoint('send', week) + '?' + query), week)
+}
+
+// 正式补发:用当前已确认的版本+hash 精确投递,成功后由服务端写入正式发送记录。
+export async function sendWeeklyMailFormal(
+ week: string,
+ revision: number,
+ bodySHA256: string
+): Promise {
+ const payload = await getPayload(endpoint('send', week), undefined, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ revision, body_sha256: bodySHA256 }),
+ })
+ return parseWeeklyMailFormalResult(payload, week)
+}
+
export async function uploadWeeklyMailImage(week: string, png: Blob): Promise {
const buffer = await png.arrayBuffer()
let binary = ''