479 lines
19 KiB
Go
479 lines
19 KiB
Go
// 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"
|
||
"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/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 != "" && question.Token == token)) {
|
||
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)原样回传,
|
||
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
|
||
// 「下一条未读」同样带上这份参数,否则答完一条后"返回"就掉回箱主页了。
|
||
if backQuery := adminBackQuery(ctx); backFrom == "admin" {
|
||
backTo := "/user/questions"
|
||
if backQuery != "" {
|
||
backTo += "?" + backQuery
|
||
}
|
||
ctx.Data["BackTo"] = backTo
|
||
ctx.Data["NextQuery"] = backQuery
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
})
|
||
|
||
// 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
|
||
}
|
||
|
||
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !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()
|
||
}
|