根因: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:
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("预览不得触发发送")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user