package question import ( stdctx "context" "fmt" "unicode/utf8" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/context" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/form" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/mail" "github.com/tamakyi/TamaBox/internal/security/moderation" "github.com/tamakyi/TamaBox/internal/shared" ) // isQuestioner 判断当前请求者是否为该提问的提问者。 // 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。 func isQuestioner(ctx context.Context, question *db.Question, token string) bool { if question.Token != "" && question.Token == token { return true } if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID { return true } return false } // PublishFollowUp 提问者在问题被回答后,用 token 继续追问。 func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishFollowUpQuestion) { // govalid 校验失败(追问内容为空)时回显表单,与 AnswerFollowUp 同路径。 // (旧实现漏掉该早退:required 失败后仍会继续 censor/入库,追问空内容也会被创建。) if ctx.HasError() { ctx.Success("question/item") return } // 追问内容长度上限(conf.Limits.FollowUpContentLen,[limits] 配置段), // 与前端追问框 maxlength 同源,rune 计数。 if utf8.RuneCountInString(f.Content) > conf.Limits.FollowUpContentLen { ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.contentTooLong", conf.Limits.FollowUpContentLen)), f) ctx.Success("question/item") return } token := ctx.Query("t") if token == "" { token = f.Token } if !isQuestioner(ctx, question, token) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNoPermission")) ctx.Redirect("/") return } // 只能对已回答的问题追问 if question.Answer == "" { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotAnswered")) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } // 追问次数限制:先快速预检查(友好提示),CreateIfBelowLimit 内再原子兜底 // (并发场景下 count-then-create 存在竞态,必须以数据库原子检查为准)。 count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), question.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups") ctx.SetInternalErrorFlash() ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } if count >= db.MaxFollowUpCount { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount)) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } // 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单, // 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。 latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up") ctx.SetInternalErrorFlash() ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } if latest != nil && latest.Answer == "" { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply")) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } if ctx.HasError() { ctx.Success("question/item") return } // 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行 // (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。 if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil { ctx.SetErrorFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } created, err := db.FollowUps.CreateIfBelowLimit(ctx.Request().Context(), db.CreateFollowUpOptions{ QuestionID: question.ID, Content: f.Content, }) if err != nil { if errors.Is(err, db.ErrFollowUpLimitReached) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount)) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up") ctx.SetInternalErrorFlash() ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return } _ = created // 邮件通知提问箱主人(开启邮件提醒时) shared.SafeGo(func() { if pageUser.Notify == db.NotifyTypeEmail { if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content, question.Token); err != nil { logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user") } } }) ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent")) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) } // AnswerFollowUp 提问箱主人回答某条追问。 func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.AnswerFollowUpQuestion) { if ctx.User.ID != pageUser.ID { ctx.Redirect("/") return } if ctx.HasError() { ctx.Success("question/item") return } // 追问的回答内容长度上限:与主回答共用 conf.Limits.AnswerContentLen // ([limits] 配置段),与前端回复框 maxlength 同源,rune 计数。 if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen { ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.answerTooLong", conf.Limits.AnswerContentLen)), f) ctx.Success("question/item") return } // 站方处置 L2 起拒绝回答追问:追问回答同样是公开内容,且没有「私密」这一档可以降级。 restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction") ctx.SetInternalError() ctx.Success("question/item") return } if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) { // 同请求渲染,用 SetError(SetErrorFlash 要等下一次请求才显示)。 ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublish")), f) ctx.Success("question/item") return } followUpID := uint(ctx.ParamInt("followUpID")) // 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。 if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil { // 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。 ctx.SetError(err, f) ctx.Success("question/item") return } if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil { if errors.Is(err, db.ErrFollowUpNotFound) { // 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。 ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f) ctx.Success("question/item") return } logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up") ctx.SetInternalError() ctx.Success("question/item") return } // 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接 shared.SafeGo(func() { if question.ReceiveReplyEmail != "" { if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil { logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner") } } }) ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered")) ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID)) }