清除业务契约双读:抽奖/草稿/时间戳/邮件只认新格式,并删除 legacy 短代码改写。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 02:20:42 +08:00
parent 3b550f2124
commit 96314893bb
9 changed files with 23 additions and 281 deletions

View File

@@ -248,7 +248,7 @@ func MatchPasswordBlocks(content, password string) ([]int, error) {
return hit, nil
}
// WrapContentAsHide 将整篇正文包进单一隐藏块(旧帖迁移用)
// WrapContentAsHide 将整篇正文包进单一隐藏块
func WrapContentAsHide(kind string, points int, content string) string {
body := strings.TrimRight(content, "\n")
open := openMarkerLine(kind, points, "", false)
@@ -258,7 +258,7 @@ func WrapContentAsHide(kind string, points int, content string) string {
return open + "\n" + body + "\n[/hide]\n"
}
// HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等)
// HasHideBlocks 判断正文是否已含隐藏块
func HasHideBlocks(content string) bool {
blocks, err := ParseHideBlocks(content)
if err != nil {

View File

@@ -177,20 +177,3 @@ func TestTimelineDoesNotBreakHideValidate(t *testing.T) {
t.Fatalf("timeline-only should pass: %v", err)
}
}
func TestRewriteLegacyDirectives(t *testing.T) {
src := "公开\n\n:::hide login\n秘\n:::\n\n:::timeline\n## 2026-01-01 A\n:::\n"
got := RewriteLegacyDirectives(src)
if strings.Contains(got, ":::") {
t.Fatalf("legacy remains: %q", got)
}
if !strings.Contains(got, "[hide login]") || !strings.Contains(got, "[/hide]") {
t.Fatalf("hide missing: %q", got)
}
if !strings.Contains(got, "[timeline]") || !strings.Contains(got, "[/timeline]") {
t.Fatalf("timeline missing: %q", got)
}
if err := ValidateHideContent(got); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,158 +0,0 @@
package markdown
import (
"strconv"
"strings"
)
// RewriteLegacyDirectives 将旧式 :::hide / :::timeline / 裸 ::: 改写为行级 BBCode。
// 幂等:已是 BBCode 的内容原样返回(若无旧标记)。代码围栏内不改写。
func RewriteLegacyDirectives(content string) string {
if content == "" {
return content
}
if !strings.Contains(content, ":::") {
return content
}
lines := splitLines(content)
var out []string
inCode := false
i := 0
for i < len(lines) {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, "```") {
inCode = !inCode
out = append(out, lines[i])
i++
continue
}
if inCode {
out = append(out, lines[i])
i++
continue
}
if kind, pts, pwd, locked, ok := parseLegacyHideOpen(trimmed); ok {
bodyLines := make([]string, 0)
j := i + 1
innerCode := false
found := false
for j < len(lines) {
inner := strings.TrimSpace(lines[j])
if strings.HasPrefix(inner, "```") {
innerCode = !innerCode
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if innerCode {
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if inner == ":::" {
found = true
break
}
bodyLines = append(bodyLines, lines[j])
j++
}
out = append(out, openMarkerLine(kind, pts, pwd, locked))
out = append(out, bodyLines...)
if found {
out = append(out, "[/hide]")
i = j + 1
} else {
// 未闭合:仍写出已转换开标记与正文,避免丢内容
i = j
}
continue
}
if trimmed == ":::timeline" || strings.HasPrefix(trimmed, ":::timeline ") {
bodyLines := make([]string, 0)
j := i + 1
innerCode := false
found := false
for j < len(lines) {
inner := strings.TrimSpace(lines[j])
if strings.HasPrefix(inner, "```") {
innerCode = !innerCode
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if innerCode {
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if inner == ":::" {
found = true
break
}
bodyLines = append(bodyLines, lines[j])
j++
}
out = append(out, "[timeline]")
out = append(out, bodyLines...)
if found {
out = append(out, "[/timeline]")
i = j + 1
} else {
i = j
}
continue
}
out = append(out, lines[i])
i++
}
return strings.Join(out, "\n")
}
// parseLegacyHideOpen 解析旧式 :::hide <kind> [arg] [locked]
func parseLegacyHideOpen(trimmed string) (kind string, points int, password string, locked bool, ok bool) {
if !strings.HasPrefix(trimmed, ":::hide") {
return "", 0, "", false, false
}
rest := strings.TrimSpace(trimmed[len(":::hide"):])
if rest == "" {
return "", 0, "", false, false
}
parts := strings.Fields(rest)
if len(parts) == 0 {
return "", 0, "", false, false
}
kind = parts[0]
switch kind {
case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword:
default:
return "", 0, "", false, false
}
idx := 1
if kind == HideKindPoints {
if idx >= len(parts) {
return "", 0, "", false, false
}
n, err := strconv.Atoi(parts[idx])
if err != nil {
return "", 0, "", false, false
}
points = n
idx++
} else if kind == HideKindPassword {
if idx < len(parts) && parts[idx] != "locked" {
password = parts[idx]
idx++
}
}
for ; idx < len(parts); idx++ {
if parts[idx] == "locked" {
locked = true
} else {
return "", 0, "", false, false
}
}
return kind, points, password, locked, true
}

View File

@@ -113,9 +113,6 @@ func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id
const defaultMailSubject = "{{site_name}} · {{purpose}}"
// 旧版内置正文:读取时若仍是此值,升级为新默认模板。
const legacyMailBody = "{{logo}}<h1>{{site_name}} · {{purpose}}</h1><p>您的验证码:</p><p><strong>{{code}}</strong></p><p>15 分钟内有效。如果不是您本人操作,请忽略本邮件。</p><p><a href=\"{{link}}\">前往站点完成{{purpose}}</a>(请粘贴以上验证码)</p>"
const defaultMailBody = `<div class="email-wrap" style="margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;">
<style>
.email-wrap{margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;}
@@ -158,8 +155,7 @@ func (c *MailConfig) applyTemplateDefaults() {
if strings.TrimSpace(c.SubjectTemplate) == "" {
c.SubjectTemplate = defaultMailSubject
}
body := strings.TrimSpace(c.BodyTemplate)
if body == "" || body == legacyMailBody {
if strings.TrimSpace(c.BodyTemplate) == "" {
c.BodyTemplate = defaultMailBody
}
}

View File

@@ -589,10 +589,11 @@ func TestMailTemplateGuards(t *testing.T) {
if cfg.BodyTemplate != defaultMailBody || cfg.SubjectTemplate != defaultMailSubject {
t.Fatal("defaults not applied")
}
cfg.BodyTemplate = legacyMailBody
customBody := "<p>{{code}}</p>"
cfg.BodyTemplate = customBody
cfg.applyTemplateDefaults()
if cfg.BodyTemplate != defaultMailBody {
t.Fatal("legacy default not upgraded")
if cfg.BodyTemplate != customBody {
t.Fatal("自定义正文不应被默认模板覆盖")
}
o := &Operations{cfg: &config.Config{SiteURL: "https://forum.example.com", DevMode: true}}
if e := o.validate(nil, "mail", &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, BodyTemplate: "<script></script><p>no code</p>"}); e == nil || !strings.Contains(e.Error(), "{{code}}") {

View File

@@ -14,9 +14,9 @@ import (
"gorm.io/gorm"
)
// 兼容旧引用:默认体积/个数(实际限额读 SettingService)
// 附件默认体积/个数上限(实际限额读 SettingService;此处供校验与测试引用)
const (
FileMaxBytes = 20 << 20 // 默认 20 MiB(缺省配置)
FileMaxBytes = 20 << 20 // 默认 20 MiB
MaxPostAttachments = 10
)

View File

@@ -49,7 +49,7 @@ type LotteryPrize struct {
type LotteryMeta struct {
Prizes []LotteryPrize `json:"prizes"`
Slots int `json:"slots"` // 由 prizes 数量合计推导,兼容旧数据
Slots int `json:"slots"` // 由 prizes 数量合计,仅便于展示/校验
Drawn bool `json:"drawn"`
WinnerIDs []uint `json:"winner_ids"`
Closed bool `json:"closed"`
@@ -122,13 +122,7 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) {
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, errors.New("抽奖配置无效")
}
// 旧数据只有 slots:合成单一奖品项
if len(m.Prizes) == 0 && m.Slots > 0 {
m.Prizes = []LotteryPrize{{Name: "奖品", Count: m.Slots}}
}
if len(m.Prizes) > 0 {
m.Slots = lotterySlotsFromPrizes(m.Prizes)
}
m.Slots = lotterySlotsFromPrizes(m.Prizes)
return &m, nil
}
@@ -592,16 +586,12 @@ func (s *PostService) buildQuestionState(post *model.Post, viewerID uint, canMan
}
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod)
solvedAt := m.SolvedAt
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
}
return &QuestionState{
Solved: m.Solved,
AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor,
AcceptedAnswer: answer,
SolvedAt: solvedAt,
SolvedAt: m.SolvedAt,
CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved,
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
@@ -688,17 +678,13 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
opts[i] = stat
}
voters := len(voterSet)
closedAt := m.ClosedAt
if m.Closed && closedAt == "" {
closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底
}
return &PollState{
Options: opts,
Multi: m.Multi,
Closed: m.Closed,
Anonymous: m.Anonymous,
EndsAt: m.EndsAt,
ClosedAt: closedAt,
ClosedAt: m.ClosedAt,
TotalVotes: total,
Voters: voters,
MyOptions: my,