diff --git a/api/openapi.yaml b/api/openapi.yaml index 425951c..b2039c7 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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", diff --git a/cmd/api/main.go b/cmd/api/main.go index 99996b0..10ce50c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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, diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 4935079..a7b2444 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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) }) diff --git a/cmd/worker/qa_weekly_mail.go b/cmd/worker/qa_weekly_mail.go new file mode 100644 index 0000000..c215f1f --- /dev/null +++ b/cmd/worker/qa_weekly_mail.go @@ -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) + } + } + } +} diff --git a/go.mod b/go.mod index ad827c1..902ddc2 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 12474eb..891f92e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/httpapi/qa_weekly_mail.go b/internal/httpapi/qa_weekly_mail.go new file mode 100644 index 0000000..171cc62 --- /dev/null +++ b/internal/httpapi/qa_weekly_mail.go @@ -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}}) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 2be85bf..c9e5700 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) }) diff --git a/internal/platform/postgres/migrations/000060_weekly_email.down.sql b/internal/platform/postgres/migrations/000060_weekly_email.down.sql new file mode 100644 index 0000000..d2ce652 --- /dev/null +++ b/internal/platform/postgres/migrations/000060_weekly_email.down.sql @@ -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; \ No newline at end of file diff --git a/internal/platform/postgres/migrations/000060_weekly_email.up.sql b/internal/platform/postgres/migrations/000060_weekly_email.up.sql new file mode 100644 index 0000000..f01e295 --- /dev/null +++ b/internal/platform/postgres/migrations/000060_weekly_email.up.sql @@ -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); \ No newline at end of file diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index d7931bb..7080c99 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -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]) diff --git a/internal/qawemail/store.go b/internal/qawemail/store.go new file mode 100644 index 0000000..805d800 --- /dev/null +++ b/internal/qawemail/store.go @@ -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 `
` + html.String() + ``, 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) + "?=" +} diff --git a/internal/qawemail/store_test.go b/internal/qawemail/store_test.go new file mode 100644 index 0000000..afe5f04 --- /dev/null +++ b/internal/qawemail/store_test.go @@ -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, "+ {error} +
+ ) : null} + {notice ?{notice}
: null} + {settings ? ( ++ {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 ? '' : ' · 未保存授权码'} +
+ ) : null} + {editing && settings ? ( ++ {error} +
+ ) : null} +