周报页新增自动发送区:管理员配置 SMTP/收件人/发送星期与小时,授权码用主密钥加密保存。 确认绑定当前草稿版本与正文哈希,正文再改则确认失效;worker 每分钟仅在启用、到点、周已结束且未发送过时发一次。 邮件同时提供渲染后的 HTML 与可读纯文本;SMTP 仅允许 TLS,拒绝明文。 迁移 000060 新增设置、确认与发送记录三张表,唯一键防重复发送。
This commit is contained in:
@@ -48,6 +48,9 @@
|
||||
"/api/v1/qa-ops/master/records/{id}/fields": {"post":{"operationId":"patchQAMasterFields","tags":["QA operations"],"summary":"只更新明确选择的字段并留幂等审计;不改原始正文、其他空白、旧复核或冻结证据。未覆盖的是否正确固定为待确认,拒绝是或否;已覆盖点踩固定为否。quality.correctness_rule=coverage-correctness-v1标记覆盖/正确性固定投影,原字段和复核证据保留","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-master-fields"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["input_hash","input_epoch","base_revision","base_review_revision","nonce","fields"],"properties":{"input_hash":{"type":"string","pattern":"^[a-f0-9]{64}$"},"input_epoch":{"type":"integer","minimum":1},"base_revision":{"type":"integer","minimum":0},"base_review_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"fields":{"type":"object","additionalProperties":false,"minProperties":1,"maxProperties":5,"properties":{"is_correct":{"type":"string","enum":["是","否","待确认"]},"product_category":{"type":"string","enum":["CR/CRA/H/AP","CRV","CR配件","Nova及系列","Nova配件","MG400","MG400配件","M1 Pro","VX500","新示教器","DobotStudio Pro","仿真软件","维护工具","教育所有产品","人形机器人","四六足机器人","X-Trainer","码垛解决方案","码垛插件","焊接解决方案","焊接插件","移动复合机器人","生态插件","生态合作","安全皮肤","未知"]},"problem_type":{"type":"string","enum":["参数","故障","使用配置","二次开发","资料获取","咨询","认证","案例","售后","培训与学习","其他"]},"root_cause":{"type":"string","enum":["知识缺失","检索不到","结果不清","答案生成错误","问题质量差","非知识范围","知识过期"]},"coverage":{"type":"string","enum":["是","否","否但不计入"]}}}}}}}}}},
|
||||
"/api/v1/qa-ops/master/weekly-drafts/{week}": {"get":{"operationId":"getQAWeeklyDraft","tags":["QA operations"],"summary":"读取租户指定北京时间ISO周的持久草稿;不存在时revision=0且不创建记录","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}}},"post":{"operationId":"saveQAWeeklyDraft","tags":["QA operations"],"summary":"防抖编辑保存:版本锁、逐版历史及同nonce重放;正文不会触发外发/归档","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-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce","markdown"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"markdown":{"type":"string","maxLength":262144,"description":"UTF-8正文最多256KiB;允许空正文,保存草稿不等于发布"}}}}}}}},
|
||||
"/api/v1/qa-ops/master/weekly-drafts/{week}/prefill": {"post":{"operationId":"prefillQAWeeklyDraft","tags":["QA operations"],"summary":"从同一正式总表截止和保存区域生成中英各六节草稿;优先匹配已上报/冻结历史指标,其余用当周实时指标;不编造FAQ调整或重点。须带当前草稿版本,防止覆盖旧编辑","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-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"}}}}}}}},
|
||||
"/api/v1/qa-ops/master/weekly-mail/settings": {"get":{"operationId":"getQAWeeklyMailSettings","tags":["QA operations"],"summary":"读取周报自动发送设置:SMTP地址/端口/账号/发件人/收件人/发送星期与小时/是否启用;永不返回授权码,仅返回是否已保存","responses":{"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"},"200":{"$ref":"#/components/responses/Envelope"},"401":{"description":"需要登录"},"503":{"description":"邮件设置存储不可用"}}},"post":{"operationId":"saveQAWeeklyMailSettings","tags":["QA operations"],"summary":"管理员保存周报自动发送设置;授权码加密存储、留空表示保持不变、清除需显式标记;启用时必须已有服务器、发件人与至少一个合法收件人;基线版本不符返回409","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":["base_revision"],"properties":{"base_revision":{"type":"integer","minimum":0},"host":{"type":"string","maxLength":200},"port":{"type":"integer","minimum":1,"maximum":65535},"username":{"type":"string","maxLength":200},"password":{"type":"string","maxLength":400},"clear_password":{"type":"boolean"},"from":{"type":"string","maxLength":320},"recipients":{"type":"array","maxItems":50,"items":{"type":"string","maxLength":320}},"weekday":{"type":"integer","minimum":1,"maximum":7},"hour":{"type":"integer","minimum":0,"maximum":23},"enabled":{"type":"boolean"}}}}}},"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/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/departments/statistics": {
|
||||
"post": {
|
||||
"operationId": "qaDepartmentStatistics",
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"knowledge-governance/internal/qaopsai"
|
||||
"knowledge-governance/internal/qaopsorg"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"knowledge-governance/internal/qawemail"
|
||||
"knowledge-governance/internal/serviceverify"
|
||||
"knowledge-governance/internal/version"
|
||||
)
|
||||
@@ -156,6 +157,7 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
}
|
||||
handler, err := httpapi.NewHandler(httpapi.Config{
|
||||
QAAI: qaopsai.NewFromEnvironment(pool, getenv, config.DatabaseURL),
|
||||
QAWeeklyMail: qawemail.NewStore(pool, qaSettings.TenantKey, getenv("QA_AI_MASTER_KEY_FILE"), nil),
|
||||
QAAITrustTLSProxy: getenv("QA_AI_TRUST_TLS_PROXY") == "true",
|
||||
QAQuality: qaopsquality.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY")),
|
||||
QAOrgSync: qaSyncStore,
|
||||
|
||||
@@ -77,6 +77,7 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
if mirrorStore != nil {
|
||||
go runRecordsSyncScheduler(ctx, mirrorStore, func(error) { log.Print("QA local mirror scheduler unavailable; last committed data retained") })
|
||||
}
|
||||
go weeklyMailLoop(ctx, pool, getenv)
|
||||
go runSourceRefreshScheduler(ctx, store, config.SourceRefreshSchedulerInterval, func(err error) {
|
||||
log.Printf("worker %s source refresh scheduler error: %v", config.WorkerID, err)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"knowledge-governance/internal/qawemail"
|
||||
)
|
||||
|
||||
// weeklyMailLoop sends the confirmed weekly report exactly inside the configured slot.
|
||||
// It runs in the ordinary worker so the console never depends on a browser staying open.
|
||||
func weeklyMailLoop(ctx context.Context, pool *pgxpool.Pool, getenv func(string) string) {
|
||||
tenant := getenv("DOBOTAI_OPS_TENANT_KEY")
|
||||
if tenant == "" {
|
||||
log.Print("weekly mail disabled: tenant key not configured")
|
||||
return
|
||||
}
|
||||
store := qawemail.NewStore(pool, tenant, getenv("QA_AI_MASTER_KEY_FILE"), nil)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastState := ""
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
result, err := store.SendDue(runCtx, time.Now())
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("weekly mail attempt failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if result.State != lastState {
|
||||
lastState = result.State
|
||||
log.Printf("weekly mail state=%s week=%s reason=%s", result.State, result.Week, result.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.4 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -52,6 +52,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qawemail"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type weeklyMailStore interface {
|
||||
ReadSettings(context.Context) (qawemail.Settings, error)
|
||||
SaveSettings(context.Context, string, qawemail.SettingsInput) (qawemail.Settings, error)
|
||||
Confirm(context.Context, string, string) (qawemail.Confirmation, error)
|
||||
Unconfirm(context.Context, string) error
|
||||
ReadConfirmation(context.Context, string) (*qawemail.Confirmation, error)
|
||||
Sends(context.Context, string) ([]qawemail.Send, error)
|
||||
SendDue(context.Context, time.Time) (qawemail.DueResult, error)
|
||||
}
|
||||
|
||||
func weeklyMailError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, qawemail.ErrConflict):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "发送设置已被其他人修改,请重新读取后再保存"})
|
||||
case errors.Is(err, qawemail.ErrInvalid):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "发送设置无效:请检查服务器、发件地址、收件人与时间"})
|
||||
case errors.Is(err, qawemail.ErrUnavailable):
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "周报邮件暂不可用,请稍后重试;未标记为已发送"})
|
||||
default:
|
||||
qaOpsError(w, dobotaiops.ErrUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func qaWeeklyMailSettings(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
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
settings, err := store.ReadSettings(r.Context())
|
||||
if err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": settings})
|
||||
case http.MethodPost:
|
||||
if !authorizeQAIntent(w, r, "qa-weekly-mail") {
|
||||
return
|
||||
}
|
||||
if identity, ok := sessionFrom(r.Context()); !ok || identity.Role != "admin" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "仅管理员可修改邮件发送设置"})
|
||||
return
|
||||
}
|
||||
var input qawemail.SettingsInput
|
||||
if decodeQualityJSON(w, r, &input) != nil {
|
||||
weeklyMailError(w, qawemail.ErrInvalid)
|
||||
return
|
||||
}
|
||||
identity, _ := sessionFrom(r.Context())
|
||||
actor := identity.Username
|
||||
settings, err := store.SaveSettings(r.Context(), actor, input)
|
||||
if err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": settings})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "不支持的方法"})
|
||||
}
|
||||
}
|
||||
|
||||
func qaWeeklyMailConfirm(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-weekly-mail") {
|
||||
return
|
||||
}
|
||||
store, ok := c.QAWeeklyMail.(weeklyMailStore)
|
||||
if !ok {
|
||||
weeklyMailError(w, qawemail.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
week := r.PathValue("week")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
confirmation, err := store.ReadConfirmation(r.Context(), week)
|
||||
if err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"confirmation": confirmation}})
|
||||
case http.MethodPost:
|
||||
identity, _ := sessionFrom(r.Context())
|
||||
actor := identity.Username
|
||||
confirmation, err := store.Confirm(r.Context(), week, actor)
|
||||
if err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"confirmation": confirmation}})
|
||||
case http.MethodDelete:
|
||||
if err := store.Unconfirm(r.Context(), week); err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"confirmation": nil}})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "不支持的方法"})
|
||||
}
|
||||
}
|
||||
|
||||
func qaWeeklyMailSends(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
|
||||
}
|
||||
sends, err := store.Sends(r.Context(), r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
weeklyMailError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"sends": sends}})
|
||||
}
|
||||
@@ -111,6 +111,7 @@ type Config struct {
|
||||
QAOrgSyncEnabled bool
|
||||
QAOrg QAOrgStore
|
||||
QAOps QAOpsReader
|
||||
QAWeeklyMail any
|
||||
QAOpsTenantKey string
|
||||
QAOpsEnabled bool
|
||||
QAOpsProblem string
|
||||
@@ -203,6 +204,12 @@ func NewHandler(config Config) (http.Handler, error) {
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftRead(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, false) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}/prefill", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, true) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/settings", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSettings(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/settings", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSettings(w, r, config) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
|
||||
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("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) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/records", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, listQAOpsRecords) })
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
drop table if exists qa_weekly_email_sends;
|
||||
drop table if exists qa_weekly_confirmations;
|
||||
drop table if exists qa_weekly_email_settings;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Weekly report e-mail: operator-confirmed drafts are sent at the configured slot.
|
||||
-- The SMTP password is sealed with the independently provisioned master key, never stored in clear.
|
||||
create table if not exists qa_weekly_email_settings (
|
||||
tenant_key text primary key,
|
||||
smtp_host text not null default '',
|
||||
smtp_port integer not null default 465 check (smtp_port between 1 and 65535),
|
||||
smtp_username text not null default '',
|
||||
smtp_password_sealed bytea,
|
||||
from_address text not null default '',
|
||||
recipients text[] not null default '{}',
|
||||
send_weekday smallint not null default 1 check (send_weekday between 1 and 7),
|
||||
send_hour smallint not null default 9 check (send_hour between 0 and 23),
|
||||
enabled boolean not null default false,
|
||||
revision bigint not null default 0,
|
||||
updated_by text not null default '',
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists qa_weekly_confirmations (
|
||||
tenant_key text not null,
|
||||
week text not null,
|
||||
draft_revision bigint not null check (draft_revision > 0),
|
||||
body_sha256 text not null,
|
||||
confirmed_by text not null,
|
||||
confirmed_at timestamptz not null default now(),
|
||||
primary key (tenant_key, week)
|
||||
);
|
||||
|
||||
create table if not exists qa_weekly_email_sends (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_key text not null,
|
||||
week text not null,
|
||||
draft_revision bigint not null,
|
||||
body_sha256 text not null,
|
||||
status text not null check (status in ('sending', 'sent', 'failed')),
|
||||
attempts integer not null default 0,
|
||||
last_error text not null default '',
|
||||
message_id text not null default '',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_key, week, draft_revision)
|
||||
);
|
||||
|
||||
create index if not exists qa_weekly_email_sends_week_idx on qa_weekly_email_sends(tenant_key, week);
|
||||
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("loadMigrations() error = %v", err)
|
||||
}
|
||||
if len(migrations) != 59 {
|
||||
t.Fatalf("migration count = %d, want 59", len(migrations))
|
||||
if len(migrations) != 60 {
|
||||
t.Fatalf("migration count = %d, want 60", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
|
||||
t.Fatalf("migration 1 = %#v", migrations[0])
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
package qawemail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("weekly mail store unavailable")
|
||||
ErrInvalid = errors.New("weekly mail invalid input")
|
||||
ErrConflict = errors.New("weekly mail settings changed")
|
||||
ErrNoPassword = errors.New("weekly mail password missing")
|
||||
)
|
||||
|
||||
// Settings never carries the SMTP password; HasPassword only says whether one is stored.
|
||||
type Settings struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
From string `json:"from"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Weekday int `json:"weekday"`
|
||||
Hour int `json:"hour"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SettingsInput struct {
|
||||
BaseRevision int64 `json:"base_revision"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ClearPassword bool `json:"clear_password"`
|
||||
From string `json:"from"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Weekday int `json:"weekday"`
|
||||
Hour int `json:"hour"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type Confirmation struct {
|
||||
Week string `json:"week"`
|
||||
DraftRevision int64 `json:"draft_revision"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
ConfirmedBy string `json:"confirmed_by"`
|
||||
ConfirmedAt time.Time `json:"confirmed_at"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
type Send struct {
|
||||
Week string `json:"week"`
|
||||
Revision int64 `json:"draft_revision"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastError string `json:"last_error"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
To []string
|
||||
From string
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
}
|
||||
|
||||
// Mailer is the only outbound dependency; tests substitute it to prove the exact wire payload.
|
||||
type Mailer interface {
|
||||
Send(ctx context.Context, cfg SMTPConfig, msg Message) (string, error)
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
tenant string
|
||||
keyFile string
|
||||
mailer Mailer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool, tenant, keyFile string, mailer Mailer) *Store {
|
||||
if mailer == nil {
|
||||
mailer = SMTPMailer{}
|
||||
}
|
||||
return &Store{pool: pool, tenant: tenant, keyFile: keyFile, mailer: mailer, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Store) valid() bool {
|
||||
return s != nil && s.pool != nil && strings.TrimSpace(s.tenant) != ""
|
||||
}
|
||||
|
||||
func (s *Store) masterKey() ([]byte, error) {
|
||||
info, err := os.Lstat(s.keyFile)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
raw, err := os.ReadFile(s.keyFile)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (s *Store) seal(secret string, revision int64) ([]byte, error) {
|
||||
key, err := s.masterKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return aead.Seal(nonce, nonce, []byte(secret), []byte(fmt.Sprintf("weekly-mail:%s:%d", s.tenant, revision))), nil
|
||||
}
|
||||
|
||||
func (s *Store) unseal(sealed []byte, revision int64) (string, error) {
|
||||
if len(sealed) == 0 {
|
||||
return "", ErrNoPassword
|
||||
}
|
||||
key, err := s.masterKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil || len(sealed) < aead.NonceSize() {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
raw, err := aead.Open(nil, sealed[:aead.NonceSize()], sealed[aead.NonceSize():], []byte(fmt.Sprintf("weekly-mail:%s:%d", s.tenant, revision)))
|
||||
if err != nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func validAddress(value string) bool {
|
||||
parsed, err := mail.ParseAddress(strings.TrimSpace(value))
|
||||
return err == nil && parsed.Address == strings.TrimSpace(value) && strings.Contains(parsed.Address, "@")
|
||||
}
|
||||
|
||||
func normalize(input SettingsInput) (SettingsInput, error) {
|
||||
input.Host = strings.TrimSpace(input.Host)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.From = strings.TrimSpace(input.From)
|
||||
seen := map[string]bool{}
|
||||
recipients := make([]string, 0, len(input.Recipients))
|
||||
for _, raw := range input.Recipients {
|
||||
address := strings.TrimSpace(raw)
|
||||
if address == "" || !validAddress(address) || seen[strings.ToLower(address)] {
|
||||
return input, ErrInvalid
|
||||
}
|
||||
seen[strings.ToLower(address)] = true
|
||||
recipients = append(recipients, address)
|
||||
}
|
||||
input.Recipients = recipients
|
||||
if input.Host != "" && !strings.Contains(input.Host, " ") && !strings.Contains(input.Host, "/") {
|
||||
if _, err := net.LookupHost(input.Host); err != nil {
|
||||
// A host that does not resolve yet stays a validation error, not a silent no-op.
|
||||
return input, ErrInvalid
|
||||
}
|
||||
}
|
||||
if input.Port == 0 {
|
||||
input.Port = 465
|
||||
}
|
||||
if input.Weekday == 0 {
|
||||
input.Weekday = 1
|
||||
}
|
||||
if input.Port < 1 || input.Port > 65535 || input.Weekday < 1 || input.Weekday > 7 || input.Hour < 0 || input.Hour > 23 {
|
||||
return input, ErrInvalid
|
||||
}
|
||||
if input.Enabled {
|
||||
if input.Host == "" || input.From == "" || !validAddress(input.From) || len(input.Recipients) == 0 {
|
||||
return input, ErrInvalid
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReadSettings(ctx context.Context) (Settings, error) {
|
||||
out := Settings{Port: 465, Weekday: 1, Hour: 9}
|
||||
if !s.valid() {
|
||||
return out, ErrUnavailable
|
||||
}
|
||||
var sealed []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT smtp_host,smtp_port,smtp_username,smtp_password_sealed,from_address,recipients,send_weekday,send_hour,enabled,revision,updated_by,updated_at FROM qa_weekly_email_settings WHERE tenant_key=$1`, s.tenant).
|
||||
Scan(&out.Host, &out.Port, &out.Username, &sealed, &out.From, &out.Recipients, &out.Weekday, &out.Hour, &out.Enabled, &out.Revision, &out.UpdatedBy, &out.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
out.HasPassword = len(sealed) > 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveSettings(ctx context.Context, actor string, input SettingsInput) (Settings, error) {
|
||||
if !s.valid() || strings.TrimSpace(actor) == "" {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
normalized, err := normalize(input)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,257))`, "weekly-mail:"+s.tenant); err != nil {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
var current int64
|
||||
var sealed []byte
|
||||
err = tx.QueryRow(ctx, `SELECT revision,smtp_password_sealed FROM qa_weekly_email_settings WHERE tenant_key=$1 FOR UPDATE`, s.tenant).Scan(¤t, &sealed)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
if current != input.BaseRevision {
|
||||
return Settings{}, ErrConflict
|
||||
}
|
||||
next := current + 1
|
||||
switch {
|
||||
case normalized.ClearPassword:
|
||||
sealed = nil
|
||||
case strings.TrimSpace(normalized.Password) != "":
|
||||
if sealed, err = s.seal(normalized.Password, next); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO qa_weekly_email_settings(tenant_key,smtp_host,smtp_port,smtp_username,smtp_password_sealed,from_address,recipients,send_weekday,send_hour,enabled,revision,updated_by,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now())
|
||||
ON CONFLICT (tenant_key) DO UPDATE SET smtp_host=excluded.smtp_host,smtp_port=excluded.smtp_port,smtp_username=excluded.smtp_username,smtp_password_sealed=excluded.smtp_password_sealed,from_address=excluded.from_address,recipients=excluded.recipients,send_weekday=excluded.send_weekday,send_hour=excluded.send_hour,enabled=excluded.enabled,revision=excluded.revision,updated_by=excluded.updated_by,updated_at=now()`,
|
||||
s.tenant, normalized.Host, normalized.Port, normalized.Username, sealed, normalized.From, normalized.Recipients, normalized.Weekday, normalized.Hour, normalized.Enabled, next, actor); err != nil {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Settings{}, ErrUnavailable
|
||||
}
|
||||
return s.ReadSettings(ctx)
|
||||
}
|
||||
|
||||
func bodyHash(markdown string) string {
|
||||
sum := sha256.Sum256([]byte(markdown))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Confirm binds the confirmation to the exact revision and body the operator just reviewed,
|
||||
// so a later edit invalidates it instead of silently mailing changed text.
|
||||
func (s *Store) Confirm(ctx context.Context, week, actor string) (Confirmation, error) {
|
||||
if !s.valid() || strings.TrimSpace(actor) == "" || strings.TrimSpace(week) == "" {
|
||||
return Confirmation{}, ErrUnavailable
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
var revision int64
|
||||
var markdown string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT revision,markdown FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2`, s.tenant, week).Scan(&revision, &markdown); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Confirmation{}, ErrInvalid
|
||||
}
|
||||
return Confirmation{}, ErrUnavailable
|
||||
}
|
||||
if revision <= 0 || strings.TrimSpace(markdown) == "" {
|
||||
return Confirmation{}, ErrInvalid
|
||||
}
|
||||
var out Confirmation
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO qa_weekly_confirmations(tenant_key,week,draft_revision,body_sha256,confirmed_by,confirmed_at)
|
||||
VALUES($1,$2,$3,$4,$5,now())
|
||||
ON CONFLICT (tenant_key,week) DO UPDATE SET draft_revision=excluded.draft_revision,body_sha256=excluded.body_sha256,confirmed_by=excluded.confirmed_by,confirmed_at=now()
|
||||
RETURNING week,draft_revision,body_sha256,confirmed_by,confirmed_at`, s.tenant, week, revision, bodyHash(markdown), actor).
|
||||
Scan(&out.Week, &out.DraftRevision, &out.BodySHA256, &out.ConfirmedBy, &out.ConfirmedAt)
|
||||
if err != nil {
|
||||
return Confirmation{}, ErrUnavailable
|
||||
}
|
||||
out.Current = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) Unconfirm(ctx context.Context, week string) error {
|
||||
if !s.valid() {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `DELETE FROM qa_weekly_confirmations WHERE tenant_key=$1 AND week=$2`, s.tenant, week); err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReadConfirmation(ctx context.Context, week string) (*Confirmation, error) {
|
||||
if !s.valid() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
var out Confirmation
|
||||
var currentRevision int64
|
||||
var currentMarkdown string
|
||||
err := s.pool.QueryRow(ctx, `SELECT c.week,c.draft_revision,c.body_sha256,c.confirmed_by,c.confirmed_at,d.revision,d.markdown
|
||||
FROM qa_weekly_confirmations c LEFT 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(&out.Week, &out.DraftRevision, &out.BodySHA256, &out.ConfirmedBy, &out.ConfirmedAt, ¤tRevision, ¤tMarkdown)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
out.Current = currentRevision == out.DraftRevision && currentMarkdown != "" && bodyHash(currentMarkdown) == out.BodySHA256
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *Store) Sends(ctx context.Context, week string) ([]Send, error) {
|
||||
if !s.valid() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT week,draft_revision,status,attempts,last_error,updated_at FROM qa_weekly_email_sends WHERE tenant_key=$1 AND ($2='' OR week=$2) ORDER BY updated_at DESC LIMIT 50`, s.tenant, week)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DueResult struct {
|
||||
State string `json:"state"`
|
||||
Week string `json:"week,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.
|
||||
func weekStart(week string) (time.Time, error) {
|
||||
var year, number int
|
||||
if _, err := fmt.Sscanf(week, "%4d-W%2d", &year, &number); err != nil || year < 2000 || year > 2200 || number < 1 || number > 53 {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, location)
|
||||
offset := (int(jan4.Weekday()) + 6) % 7
|
||||
start := jan4.AddDate(0, 0, -offset+7*(number-1))
|
||||
if _, got := start.ISOWeek(); got != number {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
return start, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
weekday := int(local.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
return weekday == settings.Weekday && local.Hour() == settings.Hour
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !now.Before(start.AddDate(0, 0, 7))
|
||||
}
|
||||
|
||||
// SendDue mails only a confirmed, still-current, already-closed week at the configured slot.
|
||||
// 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() {
|
||||
return DueResult{}, ErrUnavailable
|
||||
}
|
||||
settings, err := s.ReadSettings(ctx)
|
||||
if err != nil {
|
||||
return DueResult{}, err
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return DueResult{State: "disabled", Reason: "未启用自动发送"}, nil
|
||||
}
|
||||
if !slotMatches(settings, now) {
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return DueResult{}, ErrUnavailable
|
||||
}
|
||||
type candidate struct {
|
||||
week string
|
||||
revision int64
|
||||
hash string
|
||||
markdown string
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) send(ctx context.Context, settings Settings, week string, revision int64, markdown string, now time.Time) (DueResult, error) {
|
||||
password, err := s.password(ctx, settings.Revision)
|
||||
if err != nil {
|
||||
return DueResult{State: "blocked", Week: week, Reason: "SMTP 密码未配置或无法解密"}, nil
|
||||
}
|
||||
html, text := renderReport(markdown)
|
||||
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'
|
||||
RETURNING id::text`, s.tenant, week, revision, bodyHash(markdown)).Scan(&sendID)
|
||||
if err != nil {
|
||||
return DueResult{}, ErrUnavailable
|
||||
}
|
||||
messageID, sendErr := s.mailer.Send(ctx, SMTPConfig{Host: settings.Host, Port: settings.Port, Username: settings.Username, Password: password},
|
||||
Message{To: settings.Recipients, From: settings.From, Subject: subject, HTML: html, Text: text})
|
||||
if sendErr != nil {
|
||||
reason := sendErr.Error()
|
||||
if len(reason) > 400 {
|
||||
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
|
||||
}
|
||||
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 DueResult{State: "sent", Week: week, SentAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) password(ctx context.Context, revision int64) (string, error) {
|
||||
var sealed []byte
|
||||
if err := s.pool.QueryRow(ctx, `SELECT smtp_password_sealed FROM qa_weekly_email_settings WHERE tenant_key=$1`, s.tenant).Scan(&sealed); err != nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
return s.unseal(sealed, revision)
|
||||
}
|
||||
|
||||
func renderReport(markdown string) (string, string) {
|
||||
converter := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
||||
var html strings.Builder
|
||||
if err := converter.Convert([]byte(markdown), &html); err != nil {
|
||||
html.Reset()
|
||||
}
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body{font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#1f2937;line-height:1.7;margin:0;padding:24px;background:#fff}
|
||||
h1{font-size:22px;border-bottom:2px solid #e5e7eb;padding-bottom:8px;margin-top:32px}
|
||||
h2{font-size:17px;margin-top:24px;color:#17324d}
|
||||
table{border-collapse:collapse}h1:first-child{margin-top:0}
|
||||
</style></head><body>` + html.String() + `</body></html>`, markdown
|
||||
}
|
||||
|
||||
// SMTPMailer speaks SMTP directly: implicit TLS on 465, STARTTLS elsewhere. Plaintext is refused.
|
||||
type SMTPMailer struct{}
|
||||
|
||||
func (SMTPMailer) Send(ctx context.Context, cfg SMTPConfig, msg Message) (string, error) {
|
||||
if cfg.Host == "" || cfg.Port <= 0 || len(msg.To) == 0 || !validAddress(msg.From) {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
address := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
|
||||
dialer := &net.Dialer{Timeout: 15 * time.Second}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if cfg.Port == 465 {
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", address)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("smtp 连接失败: %w", err)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return "", fmt.Errorf("smtp 握手失败: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if cfg.Port != 465 {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return "", errors.New("smtp 服务器不支持 STARTTLS,已拒绝明文发送")
|
||||
}
|
||||
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return "", fmt.Errorf("smtp STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
|
||||
return "", fmt.Errorf("smtp 登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(msg.From); err != nil {
|
||||
return "", fmt.Errorf("smtp MAIL FROM 被拒: %w", err)
|
||||
}
|
||||
for _, to := range msg.To {
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return "", fmt.Errorf("smtp RCPT 被拒: %w", err)
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("smtp DATA 被拒: %w", err)
|
||||
}
|
||||
messageID := fmt.Sprintf("<%s@%s>", hex.EncodeToString(randomBytes(12)), domainOf(msg.From))
|
||||
boundary := hex.EncodeToString(randomBytes(10))
|
||||
if _, err = writer.Write([]byte(buildMIME(msg, messageID, boundary))); err != nil {
|
||||
return "", fmt.Errorf("smtp 写入失败: %w", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("smtp 提交失败: %w", err)
|
||||
}
|
||||
_ = client.Quit()
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
func randomBytes(n int) []byte {
|
||||
out := make([]byte, n)
|
||||
_, _ = rand.Read(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func domainOf(address string) string {
|
||||
if at := strings.LastIndex(address, "@"); at >= 0 {
|
||||
return address[at+1:]
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func buildMIME(msg Message, messageID, boundary string) string {
|
||||
var body strings.Builder
|
||||
body.WriteString("From: " + msg.From + "\r\n")
|
||||
body.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
|
||||
body.WriteString("Subject: " + mimeSubject(msg.Subject) + "\r\n")
|
||||
body.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
body.WriteString("Message-ID: " + messageID + "\r\n")
|
||||
body.WriteString("MIME-Version: 1.0\r\n")
|
||||
body.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
|
||||
body.WriteString("--" + boundary + "\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n")
|
||||
body.WriteString(base64Lines(msg.Text))
|
||||
body.WriteString("\r\n--" + boundary + "\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n")
|
||||
body.WriteString(base64Lines(msg.HTML))
|
||||
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return body.String()
|
||||
}
|
||||
|
||||
func base64Std(value string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
func base64Lines(value string) string {
|
||||
encoded := base64Std(value)
|
||||
var out strings.Builder
|
||||
for len(encoded) > 76 {
|
||||
out.WriteString(encoded[:76] + "\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
out.WriteString(encoded)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func mimeSubject(value string) string {
|
||||
return "=?UTF-8?B?" + base64Std(value) + "?="
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package qawemail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSlotMatchesUsesBeijingWeekdayAndHour(t *testing.T) {
|
||||
settings := Settings{Weekday: 1, Hour: 9}
|
||||
// Monday 09:30 Beijing == Monday 01:30 UTC.
|
||||
if !slotMatches(settings, time.Date(2026, 10, 26, 1, 30, 0, 0, time.UTC)) {
|
||||
t.Fatal("Monday 09:30 Beijing must match a Monday-09 slot")
|
||||
}
|
||||
if slotMatches(settings, time.Date(2026, 10, 26, 2, 30, 0, 0, time.UTC)) {
|
||||
t.Fatal("Monday 10:30 Beijing must not match a Monday-09 slot")
|
||||
}
|
||||
// Sunday 23:30 UTC is Monday 07:30 Beijing: still Monday, wrong hour.
|
||||
if slotMatches(settings, time.Date(2026, 10, 25, 23, 30, 0, 0, time.UTC)) {
|
||||
t.Fatal("Sunday 23:30 UTC is not the configured hour")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportClosedOnlyAfterTheISOWeekEnds(t *testing.T) {
|
||||
if reportClosed("2026-W43", time.Date(2026, 10, 25, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Fatal("a week is not closed before its Monday boundary")
|
||||
}
|
||||
if !reportClosed("2026-W43", time.Date(2026, 10, 26, 1, 0, 0, 0, time.UTC)) {
|
||||
t.Fatal("week 43 must be closed after Monday 00:00 Beijing")
|
||||
}
|
||||
if reportClosed("2026-W99", time.Now()) || reportClosed("2026-W00", time.Now()) {
|
||||
t.Fatal("impossible ISO weeks must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if _, err := normalize(SettingsInput{Host: "smtp.example.com", From: "kb@example.com", Recipients: []string{"not-an-address"}, Enabled: true}); err == nil {
|
||||
t.Fatal("a malformed recipient must be rejected")
|
||||
}
|
||||
good, err := normalize(SettingsInput{Host: "smtp.example.com", From: "kb@example.com", Recipients: []string{"a@example.com", "b@example.com"}, Enabled: true})
|
||||
if err != nil || good.Port != 465 || good.Weekday != 1 || len(good.Recipients) != 2 {
|
||||
t.Fatalf("defaults not applied: %+v %v", good, err)
|
||||
}
|
||||
duplicated := SettingsInput{Host: "smtp.example.com", From: "kb@example.com", Recipients: []string{"a@example.com", "A@example.com"}}
|
||||
if _, err := normalize(duplicated); err != ErrInvalid {
|
||||
t.Fatal("case-insensitive duplicate recipients must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMEProvidesReadableTextAndHTMLWithEncodedSubject(t *testing.T) {
|
||||
html, text := renderReport("# 知识库周报 2026-W43\n\n## 二、核心指标\n\n正确率:90.66%\n")
|
||||
if !strings.Contains(html, "<h1>") || !strings.Contains(html, "90.66%") {
|
||||
t.Fatalf("rendered weekly HTML missing structure or metrics: %s", html[:120])
|
||||
}
|
||||
if !strings.Contains(text, "正确率:90.66%") {
|
||||
t.Fatal("plain text body must keep the readable report")
|
||||
}
|
||||
raw := buildMIME(Message{From: "kb@example.com", To: []string{"lead@example.com"}, Subject: "知识库周报 2026-W43", HTML: html, Text: text}, "<id@example.com>", "BOUND")
|
||||
for _, want := range []string{"Content-Type: multipart/alternative", "text/plain", "text/html", "Message-ID: <id@example.com>", "Subject: =?UTF-8?B?"} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Fatalf("MIME payload missing %q", want)
|
||||
}
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("知识库周报 2026-W43"))
|
||||
if !strings.Contains(raw, encoded) {
|
||||
t.Fatal("subject must be RFC2047-encoded so Chinese survives clients")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMailer struct {
|
||||
sent []Message
|
||||
configs []SMTPConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMailer) Send(_ context.Context, cfg SMTPConfig, msg Message) (string, error) {
|
||||
f.configs = append(f.configs, cfg)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
f.sent = append(f.sent, msg)
|
||||
return "<fake@example.com>", nil
|
||||
}
|
||||
|
||||
func TestMailerRefusesPlaintextServersWithoutSTARTTLS(t *testing.T) {
|
||||
if _, err := (SMTPMailer{}).Send(context.Background(), SMTPConfig{Host: "127.0.0.1", Port: 25}, Message{From: "kb@example.com", To: []string{"a@example.com"}, Subject: "x"}); err == nil {
|
||||
t.Fatal("a server that cannot encrypt must be refused rather than sent in clear")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { weeklyTransport } from './qaWeeklyDraftAPI'
|
||||
import { buildQAWeeklyCSV, downloadQAWeekly, weeklyQualitySourceLabel } from './qaWeekly'
|
||||
import { QAAnswerMarkdown } from './QAAnswerMarkdown'
|
||||
import { copyRenderedWeeklyReport } from './qaWeeklyClipboard'
|
||||
import { QAWeeklyMail } from './QAWeeklyMail'
|
||||
import { QAPeriodInput } from './QAPeriodInput'
|
||||
import './qaWeeklyEditor.css'
|
||||
type LeaveGuard = (() => Promise<boolean>) | null
|
||||
@@ -175,6 +176,7 @@ export function QAWeekly({ onBeforeLeave }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<QAWeeklyMail week={state.week} busy={blocked} />
|
||||
<div className="qa-weekly-editor-toolbar">
|
||||
<span role="status" aria-live="polite" className="qa-weekly-editor-status" data-weekly-save-state={status}>
|
||||
自动保存 ·{' '}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useOptionalAuth } from './auth'
|
||||
import {
|
||||
confirmWeeklyMail,
|
||||
fetchWeeklyMailConfirmation,
|
||||
fetchWeeklyMailSends,
|
||||
fetchWeeklyMailSettings,
|
||||
saveWeeklyMailSettings,
|
||||
weeklyMailWeekdayLabels,
|
||||
} from './qaWeeklyMail'
|
||||
import type { WeeklyMailConfirmation, WeeklyMailSend, WeeklyMailSettings } from './qaWeeklyMail'
|
||||
import './qaWeeklyMail.css'
|
||||
|
||||
const stamp = (value: string) => (value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—')
|
||||
|
||||
export function QAWeeklyMail({ week, busy }: { week: string; busy: boolean }) {
|
||||
const auth = useOptionalAuth()
|
||||
const admin = auth?.identity?.role === 'admin'
|
||||
const [confirmation, setConfirmation] = useState<WeeklyMailConfirmation | null>(null)
|
||||
const [settings, setSettings] = useState<WeeklyMailSettings | null>(null)
|
||||
const [sends, setSends] = useState<WeeklyMailSend[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [working, setWorking] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [nextConfirmation, nextSends, nextSettings] = await Promise.all([
|
||||
fetchWeeklyMailConfirmation(week),
|
||||
fetchWeeklyMailSends(week),
|
||||
admin ? fetchWeeklyMailSettings() : Promise.resolve(null),
|
||||
])
|
||||
setConfirmation(nextConfirmation)
|
||||
setSends(nextSends)
|
||||
if (nextSettings) setSettings(nextSettings)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '周报邮件状态读取失败')
|
||||
}
|
||||
}, [week, admin])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
const toggleConfirm = async (confirm: boolean) => {
|
||||
if (working) return
|
||||
setWorking(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const next = await confirmWeeklyMail(week, confirm)
|
||||
setConfirmation(next)
|
||||
setNotice(confirm ? '已确认本周周报;到设定时间会自动发送,正文改动后需重新确认' : '已取消确认,不会自动发送')
|
||||
setSends(await fetchWeeklyMailSends(week))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '确认失败')
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stale = confirmation !== null && !confirmation.current
|
||||
return (
|
||||
<section className="qa-weekly-mail" aria-label="周报自动发送">
|
||||
<header>
|
||||
<h4>自动发送</h4>
|
||||
<span className="qa-weekly-mail-state" data-state={!confirmation ? 'none' : stale ? 'stale' : 'confirmed'}>
|
||||
{!confirmation
|
||||
? '未确认'
|
||||
: stale
|
||||
? `已过期(确认于第${confirmation.draft_revision}版,正文已改,需重新确认)`
|
||||
: `已确认 · 第${confirmation.draft_revision}版 · ${stamp(confirmation.confirmed_at)} · ${confirmation.confirmed_by}`}
|
||||
</span>
|
||||
<div className="qa-weekly-mail-actions">
|
||||
{confirmation ? (
|
||||
<button type="button" disabled={working || busy} onClick={() => void toggleConfirm(false)}>
|
||||
取消确认
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={working || busy} onClick={() => void toggleConfirm(true)}>
|
||||
确认本周周报
|
||||
</button>
|
||||
)}
|
||||
{admin ? (
|
||||
<button type="button" disabled={working} onClick={() => setEditing(!editing)}>
|
||||
{editing ? '收起发送设置' : '发送设置'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
{error ? (
|
||||
<p role="alert" className="error-banner">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? <p role="status">{notice}</p> : null}
|
||||
{settings ? (
|
||||
<p className="qa-weekly-mail-summary">
|
||||
{settings.enabled ? '自动发送已启用' : '自动发送未启用'} ·{' '}
|
||||
{settings.host ? `${settings.host}:${settings.port}` : '未配置邮件服务器'} ·{' '}
|
||||
{settings.recipients.length ? `${settings.recipients.length} 位收件人` : '未配置收件人'} ·{' '}
|
||||
{weeklyMailWeekdayLabels[settings.weekday - 1] ?? '—'} {String(settings.hour).padStart(2, '0')}:00(北京时间)
|
||||
{settings.has_password ? '' : ' · 未保存授权码'}
|
||||
</p>
|
||||
) : null}
|
||||
{editing && settings ? (
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
onSaved={(next) => {
|
||||
setSettings(next)
|
||||
setEditing(false)
|
||||
setNotice('发送设置已保存')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sends.length ? (
|
||||
<ul className="qa-weekly-mail-sends">
|
||||
{sends.map((send) => (
|
||||
<li key={send.week + ':' + send.draft_revision} data-status={send.status}>
|
||||
{send.week} · 第{send.draft_revision}版 ·{' '}
|
||||
{{ sending: '发送中', sent: '已发送', failed: '发送失败' }[send.status]} · {stamp(send.updated_at)}
|
||||
{send.last_error ? ` · ${send.last_error}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsForm({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: WeeklyMailSettings
|
||||
onSaved: (next: WeeklyMailSettings) => void
|
||||
}) {
|
||||
const [host, setHost] = useState(settings.host)
|
||||
const [port, setPort] = useState(String(settings.port))
|
||||
const [username, setUsername] = useState(settings.username)
|
||||
const [password, setPassword] = useState('')
|
||||
const [clearPassword, setClearPassword] = useState(false)
|
||||
const [from, setFrom] = useState(settings.from)
|
||||
const [recipients, setRecipients] = useState(settings.recipients.join('\n'))
|
||||
const [weekday, setWeekday] = useState(settings.weekday)
|
||||
const [hour, setHour] = useState(String(settings.hour))
|
||||
const [enabled, setEnabled] = useState(settings.enabled)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const next = await saveWeeklyMailSettings(settings.revision, {
|
||||
host: host.trim(),
|
||||
port: Number(port),
|
||||
username: username.trim(),
|
||||
password,
|
||||
clear_password: clearPassword,
|
||||
from: from.trim(),
|
||||
recipients: recipients
|
||||
.split(/[\n,,;;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
weekday,
|
||||
hour: Number(hour),
|
||||
enabled,
|
||||
})
|
||||
setPassword('')
|
||||
onSaved(next)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qa-weekly-mail-form">
|
||||
<label>
|
||||
邮件服务器
|
||||
<input value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" />
|
||||
</label>
|
||||
<label>
|
||||
端口
|
||||
<input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" placeholder="465" />
|
||||
</label>
|
||||
<label>
|
||||
账号
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="kb@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
授权码
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setClearPassword(false)
|
||||
}}
|
||||
placeholder={settings.has_password ? '已保存,留空保持不变' : '首次配置需填写'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{settings.has_password ? (
|
||||
<label className="qa-weekly-mail-inline">
|
||||
<input type="checkbox" checked={clearPassword} onChange={(e) => setClearPassword(e.target.checked)} />
|
||||
清除已保存授权码
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
发件地址
|
||||
<input value={from} onChange={(e) => setFrom(e.target.value)} placeholder="kb@example.com" />
|
||||
</label>
|
||||
<label className="qa-weekly-mail-wide">
|
||||
收件人(每行一个)
|
||||
<textarea rows={3} value={recipients} onChange={(e) => setRecipients(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
发送日
|
||||
<select value={weekday} onChange={(e) => setWeekday(Number(e.target.value))}>
|
||||
{weeklyMailWeekdayLabels.map((label, index) => (
|
||||
<option key={label} value={index + 1}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
发送小时(北京时间)
|
||||
<input value={hour} onChange={(e) => setHour(e.target.value)} inputMode="numeric" />
|
||||
</label>
|
||||
<label className="qa-weekly-mail-inline">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
启用自动发送
|
||||
</label>
|
||||
<div className="qa-weekly-mail-submit">
|
||||
<button type="button" disabled={saving} onClick={() => void submit()}>
|
||||
{saving ? '保存中…' : '保存发送设置'}
|
||||
</button>
|
||||
<small>只在已确认、且该周已结束后发送一次;正文改动后确认失效。</small>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="error-banner">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.qa-weekly-mail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dce4ed;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.qa-weekly-mail > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qa-weekly-mail h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #17324d;
|
||||
}
|
||||
.qa-weekly-mail-state {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.qa-weekly-mail-state[data-state='confirmed'] {
|
||||
color: #2d704d;
|
||||
}
|
||||
.qa-weekly-mail-state[data-state='stale'] {
|
||||
color: #b42318;
|
||||
}
|
||||
.qa-weekly-mail-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.qa-weekly-mail-summary {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.qa-weekly-mail-sends {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.qa-weekly-mail-sends li[data-status='failed'] {
|
||||
color: #b42318;
|
||||
}
|
||||
.qa-weekly-mail-sends li[data-status='sent'] {
|
||||
color: #2d704d;
|
||||
}
|
||||
.qa-weekly-mail-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e3e9f0;
|
||||
}
|
||||
.qa-weekly-mail-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.qa-weekly-mail-form input,
|
||||
.qa-weekly-mail-form select,
|
||||
.qa-weekly-mail-form textarea {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
.qa-weekly-mail-form .qa-weekly-mail-wide,
|
||||
.qa-weekly-mail-form .qa-weekly-mail-submit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.qa-weekly-mail-form .qa-weekly-mail-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.qa-weekly-mail-submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qa-weekly-mail-submit small {
|
||||
color: #64748b;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.qa-weekly-mail-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseWeeklyMailConfirmation, parseWeeklyMailSends, parseWeeklyMailSettings } from './qaWeeklyMail'
|
||||
import { QAWeeklyMail } from './QAWeeklyMail'
|
||||
|
||||
const settings = {
|
||||
host: 'smtp.example.com',
|
||||
port: 465,
|
||||
username: 'kb@example.com',
|
||||
from: 'kb@example.com',
|
||||
recipients: ['a@example.com'],
|
||||
weekday: 1,
|
||||
hour: 9,
|
||||
enabled: true,
|
||||
has_password: true,
|
||||
revision: 3,
|
||||
updated_by: 'admin',
|
||||
updated_at: '2026-10-25T07:00:00Z',
|
||||
}
|
||||
|
||||
describe('weekly mail settings contract', () => {
|
||||
it('accepts a complete settings payload and rejects impossible slots', () => {
|
||||
expect(parseWeeklyMailSettings(settings)).toMatchObject({ port: 465, weekday: 1, hour: 9, enabled: true })
|
||||
expect(() => parseWeeklyMailSettings({ ...settings, weekday: 0 })).toThrow()
|
||||
expect(() => parseWeeklyMailSettings({ ...settings, weekday: 8 })).toThrow()
|
||||
expect(() => parseWeeklyMailSettings({ ...settings, hour: 24 })).toThrow()
|
||||
expect(() => parseWeeklyMailSettings({ ...settings, port: 0 })).toThrow()
|
||||
expect(() => parseWeeklyMailSettings({ ...settings, recipients: [42] })).toThrow()
|
||||
})
|
||||
it('distinguishes a missing confirmation from an expired one', () => {
|
||||
expect(parseWeeklyMailConfirmation(null, '2026-W43')).toBeNull()
|
||||
const live = parseWeeklyMailConfirmation(
|
||||
{
|
||||
week: '2026-W43',
|
||||
draft_revision: 2,
|
||||
body_sha256: 'a',
|
||||
confirmed_by: 'admin',
|
||||
confirmed_at: '2026-10-25T07:00:00Z',
|
||||
current: false,
|
||||
},
|
||||
'2026-W43'
|
||||
)
|
||||
expect(live?.current).toBe(false)
|
||||
expect(() => parseWeeklyMailConfirmation({ week: '2026-W42', current: true }, '2026-W43')).toThrow()
|
||||
})
|
||||
it('rejects unknown delivery states instead of mislabelling them as sent', () => {
|
||||
expect(
|
||||
parseWeeklyMailSends([
|
||||
{
|
||||
week: '2026-W43',
|
||||
draft_revision: 1,
|
||||
status: 'sent',
|
||||
attempts: 1,
|
||||
last_error: '',
|
||||
updated_at: '2026-10-25T07:00:00Z',
|
||||
},
|
||||
])
|
||||
).toHaveLength(1)
|
||||
expect(() =>
|
||||
parseWeeklyMailSends([
|
||||
{ week: '2026-W43', draft_revision: 1, status: 'delivered', attempts: 1, last_error: '', updated_at: '' },
|
||||
])
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the confirmation gate before any automatic send controls', () => {
|
||||
const html = renderToStaticMarkup(<QAWeeklyMail week="2026-W43" busy={false} />)
|
||||
expect(html).toContain('自动发送')
|
||||
expect(html).toContain('未确认')
|
||||
expect(html).toContain('确认本周周报')
|
||||
expect(html).not.toContain('立即发送')
|
||||
expect(html).not.toContain('发送设置')
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { getPayload } from './qaOps'
|
||||
|
||||
export interface WeeklyMailSettings {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
from: string
|
||||
recipients: string[]
|
||||
weekday: number
|
||||
hour: number
|
||||
enabled: boolean
|
||||
has_password: boolean
|
||||
revision: number
|
||||
updated_by: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WeeklyMailConfirmation {
|
||||
week: string
|
||||
draft_revision: number
|
||||
body_sha256: string
|
||||
confirmed_by: string
|
||||
confirmed_at: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export interface WeeklyMailSend {
|
||||
week: string
|
||||
draft_revision: number
|
||||
status: 'sending' | 'sent' | 'failed'
|
||||
attempts: number
|
||||
last_error: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const bad = (): never => {
|
||||
throw new Error('周报邮件响应无效,未显示或未标记为已保存')
|
||||
}
|
||||
const obj = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : bad()
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : bad())
|
||||
const num = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : bad())
|
||||
|
||||
export function parseWeeklyMailSettings(value: unknown): WeeklyMailSettings {
|
||||
const v = obj(value)
|
||||
if (!Array.isArray(v.recipients) || v.recipients.some((item) => typeof item !== 'string')) return bad()
|
||||
if (typeof v.enabled !== 'boolean' || typeof v.has_password !== 'boolean') return bad()
|
||||
const weekday = num(v.weekday),
|
||||
hour = num(v.hour),
|
||||
port = num(v.port)
|
||||
if (weekday < 1 || weekday > 7 || hour > 23 || port < 1 || port > 65535) return bad()
|
||||
return {
|
||||
host: str(v.host),
|
||||
port,
|
||||
username: str(v.username),
|
||||
from: str(v.from),
|
||||
recipients: v.recipients as string[],
|
||||
weekday,
|
||||
hour,
|
||||
enabled: v.enabled,
|
||||
has_password: v.has_password,
|
||||
revision: num(v.revision),
|
||||
updated_by: str(v.updated_by),
|
||||
updated_at: v.updated_at === null ? '' : str(v.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWeeklyMailConfirmation(value: unknown, week: string): WeeklyMailConfirmation | null {
|
||||
if (value === null) return null
|
||||
const v = obj(value)
|
||||
if (v.week !== week || typeof v.current !== 'boolean') return bad()
|
||||
return {
|
||||
week,
|
||||
draft_revision: num(v.draft_revision),
|
||||
body_sha256: str(v.body_sha256),
|
||||
confirmed_by: str(v.confirmed_by),
|
||||
confirmed_at: str(v.confirmed_at),
|
||||
current: v.current,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWeeklyMailSends(value: unknown): WeeklyMailSend[] {
|
||||
if (!Array.isArray(value)) return bad()
|
||||
return value.map((raw) => {
|
||||
const v = obj(raw)
|
||||
if (!['sending', 'sent', 'failed'].includes(str(v.status))) return bad()
|
||||
return {
|
||||
week: str(v.week),
|
||||
draft_revision: num(v.draft_revision),
|
||||
status: v.status as WeeklyMailSend['status'],
|
||||
attempts: num(v.attempts),
|
||||
last_error: str(v.last_error),
|
||||
updated_at: str(v.updated_at),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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' }
|
||||
|
||||
export async function fetchWeeklyMailSettings(): Promise<WeeklyMailSettings> {
|
||||
return parseWeeklyMailSettings(((await getPayload(endpoint('settings'))) as { data: unknown }).data)
|
||||
}
|
||||
|
||||
export async function saveWeeklyMailSettings(
|
||||
base: number,
|
||||
form: Omit<WeeklyMailSettings, 'has_password' | 'revision' | 'updated_by' | 'updated_at'> & {
|
||||
password: string
|
||||
clear_password: boolean
|
||||
}
|
||||
): Promise<WeeklyMailSettings> {
|
||||
const payload = await getPayload(endpoint('settings'), undefined, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
base_revision: base,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
clear_password: form.clear_password,
|
||||
from: form.from,
|
||||
recipients: form.recipients,
|
||||
weekday: form.weekday,
|
||||
hour: form.hour,
|
||||
enabled: form.enabled,
|
||||
}),
|
||||
})
|
||||
return parseWeeklyMailSettings((payload as { data: unknown }).data)
|
||||
}
|
||||
|
||||
export async function fetchWeeklyMailConfirmation(week: string): Promise<WeeklyMailConfirmation | null> {
|
||||
const payload = await getPayload(endpoint('confirmations', week))
|
||||
return parseWeeklyMailConfirmation((payload as { data: { confirmation: unknown } }).data.confirmation, week)
|
||||
}
|
||||
|
||||
export async function confirmWeeklyMail(week: string, confirm: boolean): Promise<WeeklyMailConfirmation | null> {
|
||||
const payload = await getPayload(endpoint('confirmations', week), undefined, {
|
||||
method: confirm ? 'POST' : 'DELETE',
|
||||
headers,
|
||||
})
|
||||
return parseWeeklyMailConfirmation((payload as { data: { confirmation: unknown } }).data.confirmation, week)
|
||||
}
|
||||
|
||||
export async function fetchWeeklyMailSends(week: string): Promise<WeeklyMailSend[]> {
|
||||
const payload = await getPayload(endpoint('sends') + '?' + new URLSearchParams({ week }).toString())
|
||||
return parseWeeklyMailSends((payload as { data: { sends: unknown } }).data.sends)
|
||||
}
|
||||
|
||||
export const weeklyMailWeekdayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
Reference in New Issue
Block a user