fix anwser token and database issus
This commit is contained in:
+17
-30
@@ -15,24 +15,17 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// returnTab 表单提交后回跳的账号设置面板:屏蔽词设置已分散展示在
|
||||
// 提问/私聊/投票三个面板,action 携带 ?tab= 来源参数,保存后停留原面板。
|
||||
// returnTab 表单提交后回跳的面板:action 携带 ?tab= 来源参数,保存后停留原面板。
|
||||
func returnTab(ctx context.Context) string {
|
||||
return profileURLWithTab(ProfileTab(ctx.Query("tab")))
|
||||
}
|
||||
|
||||
// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入。
|
||||
// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入,不触碰既有配置。
|
||||
//
|
||||
// 背景:form.Bind 在反序列化失败(如请求体超限、body 畸形)时只置位错误并继续执行
|
||||
// handler 链(flamego 语义:响应未写即自动续链),零值结构体会照常进入业务动作——
|
||||
// 对不检查 ctx.HasError() 的 handler,会把用户既有配置静默清空/关闭,例如:
|
||||
// - UpdateHarassment:RegisterOnly="" → 防骚扰被改回 none(关闭"仅允许注册用户");
|
||||
// - UpdateBlockWords:BlockWords="" → 全部屏蔽词被清空;
|
||||
// - UpdateTags:Tags="" → 标签被清空、ShowInHome 被关;
|
||||
// - UpdatePagePassword / UpdateAskPassword:Password="" → 访问/提问密码被解除。
|
||||
//
|
||||
// 错误信息已由 Bind 经 context.SetError 写入 Data["Error"](govalid 校验错误已按界面
|
||||
// 语言翻译,解析错误为原文),此处转入 flash,保证 PRG 重定向后仍能向用户展示。
|
||||
// ⚠️ form.Bind 反序列化失败(请求体超限、body 畸形)时只置位错误并继续走 handler 链
|
||||
// (flamego 语义:响应未写即自动续链),零值结构体会进入业务动作,把用户既有配置静默清空:
|
||||
// 防骚扰被改回 none、屏蔽词/标签被清空、访问与提问密码被解除。
|
||||
// 错误信息已由 Bind 写入 Data["Error"],此处转入 flash 以便 PRG 重定向后仍可展示。
|
||||
func abortOnFormError(ctx context.Context, redirectTo string) bool {
|
||||
if !ctx.HasError() {
|
||||
return false
|
||||
@@ -44,12 +37,11 @@ func abortOnFormError(ctx context.Context, redirectTo string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateHarassment 仅更新「防骚扰类型」(none / register_only,即"仅允许注册用户向我提问")。
|
||||
// 只写 harassment_setting 列;全局屏蔽词已拆分为独立入口 UpdateBlockWords,
|
||||
// 二者互不覆盖(此前同一个表单一次写两列,面板拆分后单侧保存会误清空另一侧)。
|
||||
// UpdateHarassment 仅更新防骚扰类型(none / register_only)。只写 harassment_setting 列:
|
||||
// 全局屏蔽词已拆分为 UpdateBlockWords,二者互不覆盖(同表单一次写两列时,
|
||||
// 面板拆分后单侧保存会误清空另一侧)。
|
||||
func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
|
||||
// 反序列化失败(如请求体超限)时零值 RegisterOnly="" 会把防骚扰静默改回 none
|
||||
// (若此前为 register_only)——先中止,不触碰现有设置。
|
||||
// 零值 RegisterOnly="" 会把防骚扰静默改回 none(见 abortOnFormError)。
|
||||
if abortOnFormError(ctx, returnTab(ctx)) {
|
||||
return
|
||||
}
|
||||
@@ -70,11 +62,10 @@ func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
}
|
||||
|
||||
// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票
|
||||
// 提交的开放文本共用同一份词表(users.block_words)。
|
||||
// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票提交的
|
||||
// 开放文本共用同一份词表(users.block_words)。
|
||||
func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
|
||||
// 反序列化失败(如请求体超限)时零值 BlockWords="" 会经 sanitizeBlockWords 得到
|
||||
// 空词表,静默清空用户全部屏蔽词——先中止,不触碰现有词表。
|
||||
// 零值 BlockWords="" 会经 sanitizeBlockWords 得到空词表,静默清空全部屏蔽词。
|
||||
if abortOnFormError(ctx, returnTab(ctx)) {
|
||||
return
|
||||
}
|
||||
@@ -101,12 +92,9 @@ func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
}
|
||||
|
||||
// sanitizeBlockWords 把输入的屏蔽词串规整为去重后的词表:
|
||||
// - 全角逗号转半角;逐词 TrimSpace;跳过空词与重复词。
|
||||
// - 单词长度按字符(rune)计数,超限(conf.Limits.MaxBlockWordLen)返回超长提示;
|
||||
// - 词总数超限(conf.Limits.MaxBlockWords)返回数量提示。
|
||||
//
|
||||
// 成功时 errKey 为空,errWord 为触发超长的具体词(供 flash 展示)。
|
||||
// sanitizeBlockWords 把输入词串规整为去重词表:全角逗号转半角、逐词 TrimSpace、
|
||||
// 跳过空词与重复词;单词长度超限(conf.Limits.MaxBlockWordLen)或总数超限
|
||||
// (conf.Limits.MaxBlockWords)返回对应提示。成功时 errKey 为空,errWord 为超长的具体词。
|
||||
func sanitizeBlockWords(raw string) (words []string, errKey string, errWord string) {
|
||||
blockWords := strings.ReplaceAll(raw, ",", ",")
|
||||
blockWords = strings.TrimSpace(blockWords)
|
||||
@@ -123,9 +111,8 @@ func sanitizeBlockWords(raw string) (words []string, errKey string, errWord stri
|
||||
}
|
||||
wordSet[word] = struct{}{}
|
||||
|
||||
// 按字符(rune)计数:早期用 len(word) 按字节计数,中文一词 3 字节,
|
||||
// 按 rune 计数:早期用 len(word) 按字节算,中文一词 3 字节,
|
||||
// 导致中文屏蔽词实际只能输 3 个字,与界面文案「最大长度为 10」不符。
|
||||
// 上限取自 conf.Limits.MaxBlockWordLen([limits] 配置段)。
|
||||
if len([]rune(word)) > conf.Limits.MaxBlockWordLen {
|
||||
return nil, "admin.flash.blockWordTooLong", word
|
||||
}
|
||||
|
||||
+48
-83
@@ -30,12 +30,10 @@ import (
|
||||
votepkg "github.com/tamakyi/TamaBox/route/vote"
|
||||
)
|
||||
|
||||
// voteTextRepliesCache 箱主表单投票"回复明细"(逐条文本)的短 TTL 缓存。
|
||||
// 该视图仅在箱主结果页展示,且需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),
|
||||
// 缓存可显著降低重算开销;结果页非强一致,30s 陈旧可接受。
|
||||
// voteTextRepliesCache 表单投票「回复明细」的短 TTL 缓存:该视图需逐条 json.Unmarshal
|
||||
// 全部提交记录(O(N)),结果页非强一致,30s 陈旧可接受。
|
||||
var voteTextRepliesCache = cache.NewTTLCache(64)
|
||||
|
||||
// voteTextRepliesCacheTTL 回复明细缓存有效期。
|
||||
const voteTextRepliesCacheTTL = 30 * time.Second
|
||||
|
||||
// ---------------- 表单解析 ----------------
|
||||
@@ -58,19 +56,15 @@ type voteForm struct {
|
||||
censorMetadata datatypes.JSON // 标题审核结果
|
||||
}
|
||||
|
||||
// censorCheck 对单段文本做内容审核。
|
||||
//
|
||||
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
|
||||
// 投票发起人本人的「审核严格度」档位决定。
|
||||
// fail-closed:审核服务异常时拒绝发布;未开启 EnableTextCensor 时底层直接放行。
|
||||
// censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫,
|
||||
// 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。
|
||||
func censorCheck(ctx context.Context, text string) error {
|
||||
_, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空字符串返回 nil。
|
||||
// 使用 time.Local 解析:datetime-local 提交的是无时区信息的时间串,按服务端本地时区解释,
|
||||
// 与 Evaluate 中 time.Now() 的比较基准保持一致(旧的 time.Parse 会解析成 UTC,产生时区偏移)。
|
||||
// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空串返回 nil。
|
||||
// 用 time.Local 而非 time.Parse:后者会解析成 UTC,与 time.Now() 比较时产生时区偏移。
|
||||
func parseTimeInput(ctx context.Context, s string) (*time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -83,11 +77,9 @@ func parseTimeInput(ctx context.Context, s string) (*time.Time, error) {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// parseVoteForm 手动解析投票表单(完整:元信息 + 类型 + 选项/字段)。
|
||||
// 说明:不使用 form.Bind —— Bind 的反射绑定会把 string 直接赋给 int 字段导致 panic,
|
||||
// 且选项/表单字段数组需要 JSON 解析,手动解析更安全。
|
||||
// draft=true 时放宽“选项/字段数量”与内容审核要求(详见 parseVoteMeta/parseOptions/parseFormFields),
|
||||
// 使草稿可随时暂存、不丢失已填内容。
|
||||
// parseVoteForm 手动解析投票表单(元信息 + 类型 + 选项/字段)。
|
||||
// 不用 form.Bind:其反射绑定会把 string 直接赋给 int 字段导致 panic,
|
||||
// 且选项/字段数组需 JSON 解析。draft=true 时放宽数量与审核要求,便于随时暂存。
|
||||
func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) {
|
||||
form, err := parseVoteMeta(ctx, draft)
|
||||
if err != nil {
|
||||
@@ -114,7 +106,6 @@ func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) {
|
||||
}
|
||||
form.opts.MaxChoices = maxChoices
|
||||
|
||||
// 选项 / 表单字段解析与校验
|
||||
if voteType == db.VoteTypeForm {
|
||||
if err := parseFormFields(ctx, form, draft); err != nil {
|
||||
return nil, err
|
||||
@@ -134,9 +125,8 @@ func parseVoteMetaForm(ctx context.Context) (*voteForm, error) {
|
||||
return parseVoteMeta(ctx, false)
|
||||
}
|
||||
|
||||
// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。
|
||||
// draft=true 时放宽校验并跳过内容审核:草稿为私有内容,保存时不应因审核服务抖动而丢失;
|
||||
// 审核统一在发布(VotePublish)时执行,确保对外公开内容仍受管控。
|
||||
// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。draft=true 时放宽校验并跳过
|
||||
// 内容审核:草稿为私有内容,不应因审核服务抖动而丢失;审核统一在发布时补齐。
|
||||
func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
|
||||
get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) }
|
||||
|
||||
@@ -193,8 +183,7 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
|
||||
// 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启,关闭复选框无法解除。
|
||||
requireCaptcha := true
|
||||
|
||||
// 该策略由业务规则固定为一人一票;不从客户端读取已废弃的配置字段,
|
||||
// 避免旧页面或手工请求写入与数据库约束冲突的多票语义。
|
||||
// 一人一票由业务规则固定;不从客户端读取已废弃字段,避免与数据库约束冲突。
|
||||
maxVotes := 1
|
||||
|
||||
startAt, err := parseTimeInput(ctx, get("start_at"))
|
||||
@@ -358,8 +347,7 @@ func parseFormFields(ctx context.Context, form *voteForm, draft bool) error {
|
||||
if o == db.CustomFieldMarker {
|
||||
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label))
|
||||
}
|
||||
// 单个选项长度与单选/多选选项同规则(vote-form.html 前端 maxlength 同源,
|
||||
// 此前仅有前端软限制,这里补齐后端兜底校验)
|
||||
// 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
|
||||
if len([]rune(o)) > conf.Limits.VoteOptionLen {
|
||||
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
|
||||
}
|
||||
@@ -414,10 +402,9 @@ func getOwnedVote(ctx context.Context, voteIDStr string) (*db.Vote, error) {
|
||||
|
||||
// ---------------- 处理器 ----------------
|
||||
|
||||
// VoteList 投票管理列表(含每票参与数统计)。
|
||||
// effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断):
|
||||
// published 且未到开始时间 → 未开始;published 且已过结束时间 → 已结束;
|
||||
// 其余与 status 字段一致。管理列表据此展示,避免"已过结束时间仍显示进行中"。
|
||||
// published 且未到开始时间 → 未开始;已过结束时间 → 已结束。
|
||||
// 避免「已过结束时间仍显示进行中」。
|
||||
func effectiveVoteStatus(v *db.Vote, now time.Time) string {
|
||||
if v.Status != db.VoteStatusPublished {
|
||||
return v.Status
|
||||
@@ -466,10 +453,8 @@ type voteItem struct {
|
||||
}
|
||||
|
||||
// buildVoteItems 将投票列表转换为管理面板所需的富信息条目。
|
||||
// QuestionList 的「我的提问箱 · 投票」面板与此前独立的投票管理列表共用,避免重复统计逻辑。
|
||||
//
|
||||
// 性能:参与数与选项数通过 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN (...)),
|
||||
// 替代原先对每票各发 2 次 COUNT 的 N+1 查询,N 张投票从 2N 条 SQL 降为 2 条。
|
||||
// 性能:参与数与选项数经 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN),
|
||||
// 替代原先每票各发 2 次 COUNT 的 N+1 查询(2N 条 SQL 降为 2 条)。
|
||||
func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem {
|
||||
lang := i18n.Default
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
@@ -479,7 +464,6 @@ func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem {
|
||||
now := time.Now()
|
||||
items := make([]*voteItem, 0, len(votes))
|
||||
|
||||
// 批量预取参与数与选项数(避免 N+1)
|
||||
ids := make([]uint, 0, len(votes))
|
||||
for _, v := range votes {
|
||||
ids = append(ids, v.ID)
|
||||
@@ -544,13 +528,11 @@ func VoteNew(ctx context.Context) {
|
||||
ctx.Success("user/vote-form")
|
||||
}
|
||||
|
||||
// renderVoteFormWithInput 在保存失败时重新渲染投票表单并回填用户已提交的内容,
|
||||
// 避免校验/审核/数据库错误导致整页输入被清空(原先 Redirect("/user/votes/new") 会丢失全部数据)。
|
||||
// renderVoteFormWithInput 在保存失败时重新渲染表单并回填已提交内容,避免整页输入被清空。
|
||||
// isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。
|
||||
//
|
||||
// flashMsg/internal 用于在同请求内直接写入 c.Data 的错误提示(而非 session flash):
|
||||
// 本项目的 flash 在请求开始时读取进 c.Data,随后写入 session 的 flash 不会被本次渲染读到,
|
||||
// 且会残留到下一次请求造成"陈旧错误"串页;故错误回填采用直接写 c.Data 的方式。
|
||||
// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
|
||||
// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
|
||||
func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) {
|
||||
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
|
||||
get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) }
|
||||
@@ -631,9 +613,9 @@ func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flas
|
||||
ctx.Success("user/vote-form")
|
||||
}
|
||||
|
||||
// VoteCreate 创建投票(默认草稿状态)。
|
||||
// 草稿保存放宽校验并跳过内容审核(见 parseVoteForm/parseVoteMeta);任何错误均回填表单而非清空输入。
|
||||
// action=publish 时:先按草稿保存,再执行发布校验(结构 + 审核),通过则直接发布;失败则保留草稿并停留在编辑页,避免重复创建。
|
||||
// VoteCreate 创建投票(默认草稿状态)。草稿保存放宽校验并跳过内容审核;
|
||||
// 任何错误均回填表单而非清空输入。action=publish 时先存草稿再做发布校验,
|
||||
// 失败则保留草稿并停留在编辑页,避免重复创建。
|
||||
func VoteCreate(ctx context.Context) {
|
||||
action := strings.TrimSpace(ctx.Request().FormValue("action"))
|
||||
|
||||
@@ -673,7 +655,6 @@ func VoteCreate(ctx context.Context) {
|
||||
|
||||
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
|
||||
|
||||
// action=publish:已保存为草稿,再做发布校验;失败则保留草稿并停留在编辑页,避免重复创建
|
||||
if action == "publish" {
|
||||
if err := validateForPublish(ctx, vote); err != nil {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
@@ -717,9 +698,8 @@ func VoteEdit(ctx context.Context) {
|
||||
// 发布后的投票锁定类型与选项/字段(避免破坏历史投票数据归因),仅可编辑元信息
|
||||
ctx.Data["LockedContent"] = vote.Status == db.VoteStatusPublished
|
||||
|
||||
// 选项 / 表单字段回填:传结构化数据,模板经 toJSON(template.JS)注入 <script>,
|
||||
// 避免 template.HTML 在 script 上下文中被 html/template JS 转义导致初始化失效。
|
||||
// 发布后的投票编辑器被锁定,回填数据不展示但保持 JS 初始化安全(非空数组)。
|
||||
// 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入 <script>,
|
||||
// 避免 template.HTML 在 script 上下文被转义导致初始化失效。
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
ctx.Data["InitialOptions"] = optionsContent(options)
|
||||
|
||||
@@ -774,8 +754,7 @@ func parseJSONStringArray(raw string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// splitOptionTokens 将单条选项文本按英文逗号、中文逗号、顿号或换行拆分为多条,
|
||||
// 兼容用户误用中文逗号/顿号输入的情况;逐项去空格并丢弃空串。
|
||||
// splitOptionTokens 按英文/中文逗号、顿号或换行拆分选项文本,兼容用户误用中文标点。
|
||||
func splitOptionTokens(s string) []string {
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == '、' || r == '\n' || r == '\r'
|
||||
@@ -789,9 +768,8 @@ func splitOptionTokens(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// VoteUpdate 更新投票。
|
||||
// 状态分级(方案 A):草稿可完整编辑(含事务重建选项/字段);进行中仅可编辑元信息
|
||||
// (类型与选项/字段锁定,避免破坏历史投票数据归因);已结束/归档完全禁止。
|
||||
// VoteUpdate 更新投票。状态分级:草稿可完整编辑(含事务重建选项/字段);
|
||||
// 进行中仅可编辑元信息(类型与选项/字段锁定,避免破坏历史数据归因);已结束/归档禁止。
|
||||
func VoteUpdate(ctx context.Context) {
|
||||
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
|
||||
if err != nil {
|
||||
@@ -817,9 +795,8 @@ func VoteUpdate(ctx context.Context) {
|
||||
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
|
||||
Title: form.opts.Title,
|
||||
Description: form.opts.Description,
|
||||
// 发布后类型/选项不可改(见 parseVoteMetaForm 注释)。db.Votes.Update 对 nil 指针字段
|
||||
// 跳过写入,故此处不传 Type/MaxChoices 即保留原值;即便将来新增更新调用漏传,也不会
|
||||
// 再把 type 写成 ""。选项本身由 ReplaceByVoteID 托管,本分支不触碰。
|
||||
// 发布后类型/选项不可改。Update 对 nil 指针字段跳过写入,故不传 Type/MaxChoices
|
||||
// 即保留原值;即便将来漏传也不会把 type 写成 ""。选项由 ReplaceByVoteID 托管。
|
||||
Visibility: form.opts.Visibility,
|
||||
VoterScope: form.opts.VoterScope,
|
||||
ShowResult: form.opts.ShowResult,
|
||||
@@ -890,9 +867,7 @@ func VoteUpdate(ctx context.Context) {
|
||||
action := strings.TrimSpace(ctx.Request().FormValue("action"))
|
||||
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
|
||||
|
||||
// action=preview:草稿已保存,跳转到预览页。
|
||||
// 预览仅对草稿有意义(发布态由 VotePreview 内部直接跳转真实参与页);
|
||||
// 此处草稿保存已先完成,可安全预览最新编辑内容。
|
||||
// action=preview:草稿已保存,跳转预览页(发布态由 VotePreview 内部跳真实参与页)。
|
||||
if action == "preview" {
|
||||
ctx.Redirect("/user/votes/" + fmt.Sprint(vote.ID) + "/preview")
|
||||
return
|
||||
@@ -925,9 +900,8 @@ func VoteUpdate(ctx context.Context) {
|
||||
ctx.Redirect(editURL + "?saved=1")
|
||||
}
|
||||
|
||||
// VotePublish 发布投票(draft → published)。
|
||||
// 草稿保存阶段已跳过内容审核,发布(对外公开)前在此统一执行结构校验 + fail-closed 内容审核,
|
||||
// 确保公开内容仍受管控;校验通过后再发布。
|
||||
// VotePublish 发布投票(draft → published)。草稿阶段跳过的结构校验与 fail-closed
|
||||
// 内容审核在此统一补齐,确保对外公开内容仍受管控。
|
||||
func VotePublish(ctx context.Context) {
|
||||
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
|
||||
if err != nil {
|
||||
@@ -959,8 +933,8 @@ func VotePublish(ctx context.Context) {
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
}
|
||||
|
||||
// validateForPublish 发布前的完整校验:结构合法性(标题/选项数/字段数/排序题选项数)+ 内容审核(fail-closed)。
|
||||
// 草稿保存已放宽校验,发布时统一补齐,确保对外公开的投票结构合法且内容合规。
|
||||
// validateForPublish 发布前补齐完整校验:结构合法性(标题/选项数/字段数/排序题选项数)
|
||||
// + fail-closed 内容审核,确保对外公开的投票结构合法且内容合规。
|
||||
func validateForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
if strings.TrimSpace(vote.Title) == "" {
|
||||
return errors.New(i18n.T(ctx.Lang(), "vote.err.publishTitleRequired"))
|
||||
@@ -991,10 +965,8 @@ func validateForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
}
|
||||
|
||||
// voteOwnerStrictness 读取投票发起人本人的「审核严格度」档位。
|
||||
//
|
||||
// 这里不用 ctx.User 的原因:发布动作未必由发起人本人触发(后台管理、补发布等场景),
|
||||
// ctx.User 有可能是操作者而非发起人,直接沿用会导致「按别人的档位审核别人箱子的内容」。
|
||||
// 取不到发起人时回退最严档位 —— 宁可沿用严格判定,也不要因为查不到就把判定放宽。
|
||||
// 不用 ctx.User:发布动作未必由发起人触发,ctx.User 可能是操作者,会导致
|
||||
// 「按别人的档位审核别人箱子的内容」。取不到发起人时回退最严档——宁严勿松。
|
||||
func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness {
|
||||
owner, err := db.Users.GetByID(ctx.Request().Context(), ownerID)
|
||||
if err != nil {
|
||||
@@ -1006,11 +978,9 @@ func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness {
|
||||
}
|
||||
|
||||
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
|
||||
// 草稿保存阶段跳过了审核,发布(对外公开)时必须补齐,确保公开内容受管控。
|
||||
func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
// 同一次发布的全部可见文本必须用「同一个」档位裁决:这里只解析一次发起人的档位并复用。
|
||||
// 否则标题走发起人档位、描述/选项走 ctx.User 档位(censorCheck 内部读 ctx.User),
|
||||
// 一旦发布动作不是发起人本人触发,同一条投票的两段文本就会落到两个账号的松紧档上。
|
||||
// 同一次发布的全部文本必须用同一个档位裁决:否则标题走发起人档位、描述/选项走
|
||||
// ctx.User 档位,一旦发布动作不是发起人触发,同一条投票会落到两个账号的松紧档上。
|
||||
strictness := voteOwnerStrictness(ctx, vote.UserID)
|
||||
check := func(text string) error {
|
||||
_, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), text)
|
||||
@@ -1050,6 +1020,8 @@ func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VoteClose 提前结束进行中的投票。
|
||||
func VoteClose(ctx context.Context) {
|
||||
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
|
||||
if err != nil {
|
||||
@@ -1170,10 +1142,7 @@ func VoteResults(ctx context.Context) {
|
||||
}
|
||||
|
||||
var formFields []*db.VoteFormField
|
||||
// 文本类字段的逐条提交内容按"字段标签"归类,内联在题目下方折叠展示;
|
||||
// 选择/评分/排序字段已在上方聚合统计,无需逐条展示。
|
||||
textReplies := map[string][]textReplyView{}
|
||||
// 所有类型统一走 LoadResults:选项型拿统计;表单型拿 FormFieldStats(字段级聚合)
|
||||
textReplies := map[string][]textReplyView{} // 文本类字段按标签归类,选择/评分/排序已聚合
|
||||
results, err := votepkg.LoadResults(ctx, vote)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load vote results")
|
||||
@@ -1213,9 +1182,8 @@ func VoteResults(ctx context.Context) {
|
||||
ctx.Success("user/vote-results")
|
||||
}
|
||||
|
||||
// sanitizeCSVCell 防止 CSV 公式注入:单元格内容以 = + - @ 等开头时,
|
||||
// Excel/Numbers 会将其解析为公式执行(参与者可在文本字段注入恶意公式)。
|
||||
// 前置单引号强制按纯文本处理。
|
||||
// sanitizeCSVCell 防止 CSV 公式注入:以 = + - @ 开头的单元格会被 Excel/Numbers
|
||||
// 当作公式执行,前置单引号强制按纯文本处理。
|
||||
func sanitizeCSVCell(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
@@ -1227,8 +1195,8 @@ func sanitizeCSVCell(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// formatFieldValue 将表单字段值格式化为可读文本(CSV 导出用):
|
||||
// 多选数组 → "A、B";排序 map → "首选:1;次选:2"(按键排序保证稳定);其余原样。
|
||||
// formatFieldValue 将表单字段值格式化为可读文本:多选数组 → "A、B";
|
||||
// 排序 map → "首选:1;次选:2"(按键排序保证稳定);其余原样。
|
||||
func formatFieldValue(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
@@ -1253,11 +1221,8 @@ func formatFieldValue(v interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
// VoteExportCSV 导出投票结果 CSV:
|
||||
// - 选项型(单选/多选/评分/排序):每选项一行(内容 + 票数/均值),末行总参与;
|
||||
// - 表单型:每行一条回复(提交时间 + 各字段列)。
|
||||
//
|
||||
// 前置 UTF-8 BOM,保证 Excel 打开中文不乱码。
|
||||
// VoteExportCSV 导出投票结果 CSV:选项型每选项一行(内容 + 票数/均值)末行总参与;
|
||||
// 表单型每行一条回复(提交时间 + 各字段列)。前置 UTF-8 BOM 保证 Excel 中文不乱码。
|
||||
func VoteExportCSV(ctx context.Context) {
|
||||
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user