This commit is contained in:
tamakyi
2026-10-19 16:46:42 +08:00
parent 3202ef9821
commit 38cd41afde
25 changed files with 8663 additions and 41 deletions
+25
View File
@@ -0,0 +1,25 @@
package vote
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tamakyi/TamaBox/internal/db"
)
// 投票链接是发布者对外分享的内容:语言必须跟着发布者走,否则中文箱主分享出去的
// 投票页会变成站点默认语言,访客看到的语言和内容对不上。
func TestApplyOwnerLocale(t *testing.T) {
data := map[string]interface{}{"Lang": "en", "LangCode": "en"}
applyOwnerLocale(data, &db.User{Language: "ja"})
assert.Equal(t, "ja", data["Lang"])
assert.Equal(t, "ja", data["LangCode"])
// 发布者没配语言:保留 Contexter 解析出的访客/站点默认值,不要清空
fallback := map[string]interface{}{"Lang": "en", "LangCode": "en"}
applyOwnerLocale(fallback, &db.User{Language: ""})
assert.Equal(t, "en", fallback["LangCode"])
}
+46 -32
View File
@@ -33,6 +33,7 @@ import (
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/theme"
)
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
@@ -43,6 +44,19 @@ func ctxLang(ctx context.Context) string {
return i18n.Default
}
// applyOwnerLocale 让界面语言与主题跟随投票发布者,与提问箱页面同一套规则:
// 投票链接是发布者对外分享的内容,访客看到的应当是他的语言与配色。
// 发布者未配置语言时保留 Contexter 解析出的值(访客 cookie / 站点默认)。
func applyOwnerLocale(data map[string]interface{}, owner *db.User) {
data["Owner"] = owner
if owner.Language != "" {
lang := i18n.Resolve(owner.Language)
data["Lang"] = lang
data["LangCode"] = lang
}
data["ThemeSlug"] = theme.Resolve(owner.Theme)
}
// resultsCache 投票结果聚合统计的短 TTL 进程内缓存。
// 结果统计需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),缓存可显著降低数据库实时重算与 CPU 开销;
// 结果页非强一致,30s 陈旧可接受(新提交最多延迟 30s 反映)。多实例部署时各实例独立缓存,不影响正确性。
@@ -596,6 +610,14 @@ func Page(ctx context.Context) {
now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
// 发布者的语言与主题:必须在任何 i18n 取词之前应用,否则「投票功能已关闭」
// 这类提示会先按访客语言生成。箱主已删除(GetByID 失败)则保持站点默认外观。
if owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID); err == nil {
applyOwnerLocale(ctx.Data, owner)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get vote owner")
}
// 箱主侧的投票功能开关(开关 / 定时开放 / 定量收集):关闭时访客不可参与,
// 页面本身仍可访问(结果展示策略不受影响)。
voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote)
@@ -609,12 +631,6 @@ func Page(ctx context.Context) {
ctx.Data["Ended"] = isVoteEnded(vote, now)
ctx.Data["VoteScopeRegisteredOnly"] = vote.VoterScope == db.VoteVoterScopeRegisteredOnly
// 箱主信息
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err == nil {
ctx.Data["Owner"] = owner
}
// 选项 / 表单字段
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
@@ -680,14 +696,24 @@ type formFieldInput struct {
// Submit 提交投票。
func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx)
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notFound"))
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
// 发布者的语言与主题:提交成功/失败都回跳投票页,提示文案必须与页面语言一致。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote owner")
ctx.SetInternalErrorFlash()
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
applyOwnerLocale(ctx.Data, owner)
lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
@@ -766,17 +792,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
voterToken = &token
}
// 解析提交内容。
// 先取投票发起人的全局屏蔽词表(users.block_words):投票提交中的开放文本
// (自定义补充选项、表单“其他”文本、文本/长文本字段)需要与提问/私聊一样
// 命中即拒,因此把 owner 一并传入 parseSubmission 做本地词表检查。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote owner")
ctx.SetInternalErrorFlash()
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
// 解析提交内容。投票发起人的全局屏蔽词表(users.block_words)用于校验开放文本:
// 自定义补充选项、表单“其他”文本、文本/长文本字段命中即拒(与提问/私聊一致)。
submission, err := parseSubmission(ctx, vote, owner)
if err != nil {
ctx.SetErrorFlash(err.Error())
@@ -822,19 +839,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
}
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)
if vote.Type == db.VoteTypeForm {
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err == nil && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
if err := mail.SendNewVoteSubmissionMail(email, title, voteID); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new vote submission mail")
}
})
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
if err := mail.SendNewVoteSubmissionMail(email, title, voteID); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new vote submission mail")
}
})
}
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))