fix(qa-weekly-mail): 修复周日发送窗口并提供正式补发
build / build (push) Successful in 3m5s

根因:SendDue 同时要求“已到配置时段”与“该周已结束(下周一00:00)”。周日23点的时段
只在周内存在,当周周报永远不满足“已结束”,于是 11/08 23:00 只报 nothing_confirmed、
11/09 00:00 又已成 not_due —— 邮件从未尝试发送,与 SMTP 凭据/网络无关。

改法(最小、不碰历史发布模块):
1. 由发送窗口反推要发的周:周日窗口发当天结束的那一周,其它工作日仍发上一完整周
   (周一09:00 语义不变)。自动发送只处理这一周,不再按“所有已结束的周”回填。
2. 认领改为只重认 failed(或首次)记录:sent 幂等返回真实 message_id;sending 视为
   结果未知,拒绝盲重发并提示核对邮箱。自动与手工共用同一条渲染/认领/投递路径,
   并发或重复点击只投递一次;失败如实写 failed,不假写 sent。
3. 新增管理员正式补发(页面按钮 + GET 预览 + POST /weekly-mail/send/{week}):精确指定
   周、已确认版本与正文 hash,必须同源操作意图;未来周拒绝,当前周须到设定时点,
   已结束周可显式补发。正文/看板图片/主送/抄送与自动发送一致,结果写入正式发送记录。
   这不是“发送测试邮件”(只发 review 地址、不登记)。

验证:真实隔离 PostgreSQL(kg-pgtest-writeset 上的独立一次性库)先 RED(旧实现真实
复现 nothing_confirmed)后 GREEN;覆盖到点前/未来周/未确认/正文变化、周一语义保持、
手工补发一次并读回 message_id、并发只投递一次、结果未知不重发、失败可重试一次。
HTTP 与前端解析用例通过;make verify 17 项全绿。
This commit is contained in:
姚章浩
2026-11-09 00:48:33 +08:00
parent 9d445f4953
commit 719876d039
15 changed files with 1395 additions and 54 deletions
+32
View File
@@ -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
+1
View File
@@ -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": {
+91
View File
@@ -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) {
+186
View File
@@ -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: "<m@example.com>"}}
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: "<real@example.com>", 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 != "<real@example.com>" || 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("预览不得触发发送")
}
}
+2
View File
@@ -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) })
@@ -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("<weekly-%d@example.com>", 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())
}
}
@@ -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")
}
+226 -51
View File
@@ -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"
+29
View File
@@ -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)
+79
View File
@@ -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 "<slot@example.com>", 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))
}
}
+45
View File
@@ -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",
+75 -1
View File
@@ -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<WeeklyMailSendPreview | null>(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 }) {
<button type="button" disabled={working || testing} onClick={() => void testSend()}>
{testing ? '正在发送测试…' : '发送测试邮件'}
</button>
{confirmation?.current ? (
<button
type="button"
disabled={working || preparingResend || resending}
onClick={() => void prepareResend()}
>
{preparingResend ? '正在核对…' : '正式补发…'}
</button>
) : null}
{confirmation?.has_image && !stale ? (
<button type="button" disabled={working || attaching} onClick={attachDashboard}>
{attaching ? '正在生成图片…' : '重新生成看板图片'}
@@ -160,6 +212,27 @@ export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
</p>
) : null}
{notice ? <p role="status">{notice}</p> : null}
{resendConfirm ? (
<div className="qa-weekly-mail-confirm" role="alertdialog" aria-label="确认正式补发">
<p>
将<b>正式补发</b>《知识库周报 {resendConfirm.week}》第{resendConfirm.draft_revision}版给{' '}
{resendConfirm.recipients} 位收件人、{resendConfirm.cc_recipients} 位抄送(正式名单,不是测试邮箱),
并写入正式发送记录。
{resendConfirm.status === 'already_sent' ? '该版本已有发送记录,确认后只读回结果、不会重复投递。' : ''}
{resendConfirm.status === 'in_flight'
? '该版本上次发送结果未知,服务端会拒绝重发,请先核对已发出的邮件。'
: ''}
</p>
<div className="qa-weekly-mail-actions">
<button type="button" disabled={resending} onClick={() => void confirmResend()}>
{resending ? '正在补发…' : '确认正式补发'}
</button>
<button type="button" disabled={resending} onClick={() => setResendConfirm(null)}>
取消
</button>
</div>
</div>
) : null}
{settings ? (
<p className="qa-weekly-mail-summary">
{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}` : ''}
</li>
))}
</ul>
+16
View File
@@ -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));
+74 -1
View File
@@ -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: '<real@example.com>',
recipients: 119,
cc_recipients: 8,
},
'2026-W45'
)
expect(sent.message_id).toBe('<real@example.com>')
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: '<x@y>',
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: '<real@example.com>',
updated_at: '2026-11-09T00:32:00+08:00',
},
])
expect(send.message_id).toBe('<real@example.com>')
})
})
+77
View File
@@ -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<string> {
return payload.message_id
}
export async function previewWeeklyMailSend(
week: string,
revision: number,
bodySHA256: string
): Promise<WeeklyMailSendPreview> {
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<WeeklyMailFormalResult> {
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<number> {
const buffer = await png.arrayBuffer()
let binary = ''