Files
tamabox/route/question/question.go
T
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 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 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00

501 lines
21 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.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"net/url"
"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/imagecheck"
"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"
)
func Questioner(ctx context.Context, pageUser *db.User) {
// 从 URL query 读取 token;追问等 POST 请求 token 在表单隐藏域中,需回退读取表单字段。
// FormValue 同时覆盖 query 与 body(含 multipart),对 GET 请求行为与 Query 一致。
token := ctx.Query("t")
if token == "" {
token = ctx.Request().FormValue("token")
}
questionID := uint(ctx.ParamInt("questionID"))
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
if err != nil {
if !errors.Is(err, db.ErrQuestionNotExist) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get question by ID")
}
ctx.Redirect("/")
return
}
ctx.Data["Question"] = question
askUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAsk, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get ask upload images")
}
ctx.Data["AskUploadImages"] = askUploadImages
answerUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAnswer, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get answer upload images")
}
ctx.Data["AnswerUploadImages"] = answerUploadImages
// Check the question is belongs to the correct page user.
// If the question has not been answered, we should check the question is belongs to the correct page user.
// The questioner can use the token to view the question.
// 注意:token 为空的未回答/私密问题同样禁止匿名访问(不能因 token 为空而放行)。
if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
!(question.Token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) {
ctx.Redirect("/")
return
}
// The page's owner or the question's token can have the permission to delete the question.
// Inject the permission into the context.
canDelete := ctx.IsLogged && ctx.User.ID == pageUser.ID
ctx.Map(canDelete)
ctx.Data["CanDelete"] = canDelete
// 箱主导航:下一条未读。只查一条(不做 count),用于决定详情页是否渲染该按钮。
if canDelete {
nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question")
} else if nextUnread != nil {
ctx.Data["NextUnreadID"] = nextUnread.ID
}
}
// ---------- 追问 ----------
followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get follow ups")
followUps = []*db.FollowUp{}
}
ctx.Data["FollowUps"] = followUps
// 提问者身份:token 匹配(匿名)或登录账号匹配(登录用户)
isQuestioner := isQuestioner(ctx, question, token)
ctx.Data["IsQuestioner"] = isQuestioner
// 是否可追问:已回答 + 提问者 + 追问次数未达上限
followUpRemaining := 0
if isQuestioner && question.Answer != "" {
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
} else {
followUpRemaining = db.MaxFollowUpCount - int(count)
}
}
// 是否可追问:已回答 + 提问者 + 追问次数未达上限 + 上一条追问已被主人回复
// (主人尚未回复上一条追问时,提问者不应继续发起新的追问)
// 等待提示同样仅对提问者展示——主人/旁观者看到的应是回复框或正常内容。
waitingFollowUpReply := false
if isQuestioner && len(followUps) > 0 && followUps[len(followUps)-1].Answer == "" {
waitingFollowUpReply = true
}
canFollowUp := isQuestioner && question.Answer != "" && followUpRemaining > 0 && !waitingFollowUpReply
ctx.Data["CanFollowUp"] = canFollowUp
ctx.Data["WaitingFollowUpReply"] = waitingFollowUpReply
ctx.Data["FollowUpRemaining"] = followUpRemaining
ctx.Data["FollowUpLimit"] = db.MaxFollowUpCount
// 供追问表单隐藏域回传(匿名提问者必须携带 token)
ctx.Data["QuestionToken"] = token
// 返回来源:admin=从管理后台(/user/questions)进入;空=从提问箱主页(/_/domain)或直接访问,
// 默认返回提问箱主页。详情页底部"返回xxx的提问箱"按钮据此决定跳转目标。
backFrom := ctx.Query("from")
ctx.Data["BackFrom"] = backFrom
// 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传,
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
// 「下一条未读」同样带上这份参数,否则答完一条后"返回"就掉回箱主页了。
// 表单 action 必须在这里拼成「完整 URL」再交给模板:写成 action="...?{{ .NextQuery }}"
// 时 html/template 会把这段当成 URL 的查询值编码,= 与 & 变成 %3d / %26,
// 服务端再也解析不出来源(实测过)。完整 URL 只会把 & 转成 &,浏览器会还原。
if backQuery := adminBackQuery(ctx); backFrom == "admin" {
backTo := "/user/questions"
answerAction := fmt.Sprintf("/_/%s/%d/answer", pageUser.Domain, question.ID)
nextUnreadAction := fmt.Sprintf("/_/%s/%d/next-unread", pageUser.Domain, question.ID)
if backQuery != "" {
backTo += "?" + backQuery
answerAction += "?" + backQuery
nextUnreadAction += "?" + backQuery
}
ctx.Data["BackTo"] = backTo
ctx.Data["AnswerAction"] = answerAction
ctx.Data["NextUnreadAction"] = nextUnreadAction
}
ctx.Map(question)
}
func Item(ctx context.Context) {
ctx.Success("question/item")
}
func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishAnswerQuestion) {
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.answer.contentTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
// 纵深防御:确认该提问归属当前页面箱主(Questioner 中间件已保证,这里二次校验防中间件改动引入越权)。
if question.UserID != pageUser.ID {
ctx.Redirect("/")
return
}
answer := f.Answer
// 站方处置:L3 起冻结发布,L2 收窄公开(回答强制转私密)。
// 处置记录只能由运维经 CLI 写入,箱主无法自行解除 —— 与 feature_controls 的自主开关严格区分。
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.RestrictionL3) {
// 这里是**同请求渲染**(Success 直接出页面,没有重定向),必须用 SetError 写
// Data["Error"];SetErrorFlash 依赖下一次请求读取,用在这里提示会静默丢失
// (线上实测:箱主点提交后页面毫无反应,以为没生效)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublish")), f)
ctx.Success("question/item")
return
}
// 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
// 已经查过处置级别,直接用,避免 EffectiveStrictness 再打一次同样的库。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), answer)
if err != nil {
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
// L2 收窄公开:审核通过后写回答,并在同一条 UPDATE 里把题目置为私密。
// 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回,
// 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。
// 也不能拆成「先 SetPrivate 再 AnswerByID」:两条语句之间有「公开 + 已作答」窗口,
// 且第二条失败会留下「私密 + 无回答」(内容凭空消失)。见 AnswerByIDPrivate 注释。
forcedPrivate := db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2)
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
Type: db.UploadImageQuestionTypeAnswer,
Image: image,
QuestionID: question.ID,
UploaderUserID: ctx.User.ID,
IsDeletingPrevious: true,
}); err != nil {
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetError(err, f)
ctx.Success("question/item")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
}
}
}
var answerErr error
if forcedPrivate {
answerErr = db.Questions.AnswerByIDPrivate(ctx.Request().Context(), question.ID, f.Answer)
} else {
answerErr = db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer)
}
if answerErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(answerErr).Error("Failed to answer question")
ctx.SetInternalError(f)
ctx.Success("question/item")
return
}
// Update censor result.
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
AnswerCensorMetadata: censorResponse.ToJSON(),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update answer censor result")
}
shared.SafeGo(func() {
// L2 下题目刚被强制转为私密:提问者没有访问权限,带 token 的链接点开也是 302。
// 这时发「你的提问有回答」只会让人以为内容丢了,因此跳过通知。
if question.ReceiveReplyEmail != "" && question.Answer == "" && !forcedPrivate { // We only send the email when the question has not been answered.
// Send notification to questioner.
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 receive reply mail to questioner")
}
}
// 站级 Webhook:新回答(回答发布时推送)。
if !forcedPrivate {
notify.Send(stdctx.Background(), "new_answer",
"新的回答", f.Answer,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
}
})
// L2 下必须明确告知「已转为私密」:单题页对箱主本人仍是 200、内容照常显示,
// 若继续提示「回答发布成功」,箱主会以为一切照旧(实测就是这样误判的)。
if forcedPrivate {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answeredPrivate"))
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered"))
}
// 「回答并下一条」:同一表单的第二个提交按钮带 next=1。答题后本条已锁定为已读,
// 直接打开最早的一条未读;队列已空(渲染后被打空的竞态)则退回当前页。
if ctx.Request().FormValue("next") == "1" {
nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question")
} else if nextUnread != nil {
ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID))
return
}
}
ctx.Redirect(questionPath(ctx, pageUser.Domain, question.ID))
}
func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete question")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
// 举报闭环:内容已被箱主处理掉,该题下的待处理举报不应再计入告警阈值。
// 这一步失败不影响删除结果,只记日志 —— 让一次统计失败回滚用户操作是不可接受的。
if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after delete")
}
ctx.Redirect("/_/" + pageUser.Domain)
}
func SetPrivate(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
// 举报闭环:设为私密等于箱主已响应,该题的待处理举报移出统计池(见 report.go 的说明)。
if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after set private")
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 站方处置 L2 起禁止「设为公开」: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(原因见 PublishAnswer 的 L3 分支)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublic")))
ctx.Success("question/item")
return
}
if err := db.Questions.SetPublic(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
// TogglePin 置顶/取消置顶(仅提问箱主人,操作入口在我的提问管理页)。
func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 条件更新:只有当前状态仍是这次读到的值时才写入。
// 原来直接 SetPinned(!question.IsPinned) 是 read-modify-write,双击时两个请求
// 都读到 false、都写 true,结果停在「置顶」与用户预期的「取消」
// 相反。现在第二次会因状态已变而影响 0 行,即「只切换一次」。
if _, err := db.Questions.SetPinnedIfCurrent(
ctx.Request().Context(), question.ID, question.IsPinned, !question.IsPinned); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.operated"))
ctx.Redirect("/user/questions")
}
// SetRead 手动标记提问为已读,跳转回管理列表并定位到该条。
func SetRead(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedRead"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}
// SetUnread 手动标记提问为未读。已回答的提问锁定为已读,不可改回未读。
// 跳转与 SetRead 一致,回到管理列表并定位到该条,保证"已读/未读"操作路径统一。
func SetUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 已回答的问题锁定为已读:上层直接拦截,避免误导用户。
if question.Answer != "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errAnsweredLocked"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
return
}
if err := db.Questions.SetUnread(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question unread")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}
// NextUnread 阅后即走:把当前提问标记为已读,然后打开最早的一条未读提问。
//
// 必须同时改状态:只跳转而不标记的话,当前这条仍是未读,下一次取「最早未读」
// 又会把它选回来,两条未读之间会来回打转。
func NextUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if nextUnread == nil {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.noUnread"))
ctx.Redirect(questionPath(ctx, pageUser.Domain, question.ID))
return
}
ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID))
}
// questionPath 拼单题页地址;从后台进入时带上筛选参数,跳转后「返回」仍回原列表。
func questionPath(ctx context.Context, domain string, questionID uint) string {
path := fmt.Sprintf("/_/%s/%d", domain, questionID)
if backQuery := adminBackQuery(ctx); backQuery != "" {
path += "?" + backQuery
}
return path
}
// adminBackQuery 提取后台列表的筛选参数(仅 from=admin 时有效),供单题页的
// 「返回」「下一条未读」沿用。POST 请求从表单 action 的 query 里读取。
// 返回值始终带 from=admin:调用方会把它重新写进表单 action 与跳转地址,
// 少一个 from,下一次 POST 就认不出来源了。
func adminBackQuery(ctx context.Context) string {
if ctx.Query("from") != "admin" {
return ""
}
// from 必须随表单 action 回到 POST:ctx.Query 只读 URL query,
// 少了它就认不出来源,「返回」会掉回箱主页。
backQuery := url.Values{"from": {"admin"}}
for _, key := range []string{"sort", "status", "type", "read", "q"} {
if v := ctx.Query(key); v != "" {
backQuery.Set(key, v)
}
}
return backQuery.Encode()
}