在现有邮件通知之外补两条广播/点对点通道: - 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。 消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。 - 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主 自填 Chat ID,新提问 / 新投票回复点对点推送。 设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错, 绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步 执行,与现有邮件通知同一条路径。 6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。 profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。 测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
239 lines
9.9 KiB
Go
239 lines
9.9 KiB
Go
package question
|
||
|
||
import (
|
||
stdctx "context"
|
||
"crypto/subtle"
|
||
"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/notify"
|
||
"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 {
|
||
// 常数时间比较:与 Pager 中间件里的校验(route/question/question.go)同一口径。
|
||
// 用 == 会按字节短路,攻击者能靠响应时间逐字节试探出 token ——
|
||
// 而 token 就是「私密提问 / 尚未回答的提问」唯一的钥匙。
|
||
if question.Token != "" && token != "" &&
|
||
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1 {
|
||
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 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
|
||
//
|
||
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
|
||
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
|
||
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
|
||
}
|
||
if errors.Is(err, db.ErrFollowUpAwaitingReply) {
|
||
// 事务层的兜底(并发双击时会走到这里)
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
|
||
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")
|
||
}
|
||
}
|
||
notify.Send(stdctx.Background(), "new_follow_up",
|
||
"新的追问", f.Content,
|
||
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||
})
|
||
|
||
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
|
||
}
|
||
|
||
wasFirst, err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer)
|
||
if 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 链接。
|
||
//
|
||
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
|
||
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
|
||
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
|
||
shared.SafeGo(func() {
|
||
if wasFirst && 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")
|
||
}
|
||
}
|
||
if wasFirst {
|
||
notify.Send(stdctx.Background(), "follow_up_answer",
|
||
"追问的回答", f.Answer,
|
||
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||
}
|
||
})
|
||
|
||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
|
||
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
|
||
}
|