Files
tamabox/route/question/follow_up.go
T

194 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.StrictnessFor(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
}
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
ctx.SetErrorFlash(err.Error())
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) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotFound"))
ctx.Success("question/item")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up")
ctx.SetInternalErrorFlash()
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))
}