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} {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 = ''