安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性

This commit is contained in:
tamakyi
2026-11-05 12:17:05 +08:00
parent 7636456d4e
commit 25aee35030
18 changed files with 1002 additions and 194 deletions
+220 -5
View File
@@ -16,6 +16,7 @@ import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/xuri/excelize/v2"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
@@ -499,8 +500,9 @@ func DeactivatedProfile(ctx context.Context) {
ctx.Success("user/deactivated")
}
// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息),
// ExportProfileJSON 以 JSON 格式导出账号数据(用户信息 + 提问/回答/追问 + 投票 + 私聊消息),
// 符合可携带权要求,供用户备份/迁移。
// FUN-01 修复:补齐投票及提交、私聊图片;子查询失败时 fail-closed 而非静默生成缺数据的文件。
func ExportProfileJSON(ctx context.Context) {
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
if err != nil {
@@ -542,12 +544,16 @@ func ExportProfileJSON(ctx context.Context) {
followUpMap, err := db.FollowUps.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get follow ups for export")
followUpMap = make(map[uint][]*db.FollowUp)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
imageMap, err := db.UploadImages.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get images for export")
imageMap = make(map[uint][]*db.UploadImage)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
exportQuestions := make([]exportedQuestion, 0, len(questions))
@@ -579,6 +585,7 @@ func ExportProfileJSON(ctx context.Context) {
Content string `json:"content"`
SenderID uint `json:"sender_id"`
CreatedAt time.Time `json:"created_at"`
Images []string `json:"images"`
}
type exportedConversation struct {
OwnerID uint `json:"owner_id"`
@@ -592,9 +599,19 @@ func ExportProfileJSON(ctx context.Context) {
allConvs := make([]*db.Conversation, 0)
if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, owned...)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list owned conversations for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, asked...)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list asked conversations for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
// 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。
seen := make(map[uint]bool)
@@ -611,8 +628,26 @@ func ExportProfileJSON(ctx context.Context) {
msgMap, err := db.Messages.GetAllByConversationIDs(ctx.Request().Context(), convIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get messages for export")
msgMap = make(map[uint][]*db.Message)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
// 批量拉取全部消息的图片(IN 查询 + 内存分组),避免逐消息查询造成 N+1。
allMessageIDs := make([]uint, 0)
for _, msgs := range msgMap {
for _, m := range msgs {
allMessageIDs = append(allMessageIDs, m.ID)
}
}
msgImageMap, err := db.MessageImages.GetByMessageIDs(ctx.Request().Context(), allMessageIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get message images for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
for _, conv := range dedupedConvs {
msgs := msgMap[conv.ID]
if msgs == nil {
@@ -620,10 +655,15 @@ func ExportProfileJSON(ctx context.Context) {
}
exportMsgs := make([]exportedMessage, 0, len(msgs))
for _, m := range msgs {
imageKeys := make([]string, 0)
for _, img := range msgImageMap[m.ID] {
imageKeys = append(imageKeys, img.ObjectKey)
}
exportMsgs = append(exportMsgs, exportedMessage{
Content: m.Content,
SenderID: m.SenderID,
CreatedAt: m.CreatedAt,
Images: imageKeys,
})
}
exportConvs = append(exportConvs, exportedConversation{
@@ -635,12 +675,187 @@ func ExportProfileJSON(ctx context.Context) {
})
}
// 投票及提交(FUN-01:补齐投票导出)
type exportedVoteOption struct {
ID uint `json:"id"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVoteFormField struct {
ID uint `json:"id"`
FieldType string `json:"field_type"`
Label string `json:"label"`
Required bool `json:"required"`
MaxLength int `json:"max_length"`
Options string `json:"options"`
SortOrder int `json:"sort_order"`
AllowCustom bool `json:"allow_custom"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVoteSubmission struct {
ID uint `json:"id"`
OptionID uint `json:"option_id"`
VoterIP string `json:"voter_ip"`
FormData datatypes.JSON `json:"form_data"`
CustomText string `json:"custom_text"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVote struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
Visibility string `json:"visibility"`
VoterScope string `json:"voter_scope"`
ShowResult string `json:"show_result"`
AllowCustom bool `json:"allow_custom"`
MaxChoices int `json:"max_choices"`
StartAt *time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
MaxVotesPerFingerprint int `json:"max_votes_per_fingerprint"`
RequireCaptcha bool `json:"require_captcha"`
QuotaLimit int `json:"quota_limit"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Options []exportedVoteOption `json:"options"`
FormFields []exportedVoteFormField `json:"form_fields"`
Submissions []exportedVoteSubmission `json:"submissions"`
}
votes, err := db.Votes.ListByUserID(ctx.Request().Context(), user.ID, db.ListVotesOptions{})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list votes for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteIDs := make([]uint, 0, len(votes))
for _, v := range votes {
voteIDs = append(voteIDs, v.ID)
}
// 批量拉取选项、字段、提交(IN 查询 + 内存分组),避免逐投票 N+1。
voteOptsMap := make(map[uint][]*db.VoteOption)
if len(voteIDs) > 0 {
for _, v := range votes {
opts, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote options for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteOptsMap[v.ID] = opts
}
}
voteFieldsMap := make(map[uint][]*db.VoteFormField)
if len(voteIDs) > 0 {
for _, v := range votes {
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote form fields for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteFieldsMap[v.ID] = fields
}
}
voteSubsMap, err := db.VoteSubmissions.ListByVoteIDs(ctx.Request().Context(), voteIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get vote submissions for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
exportVotes := make([]exportedVote, 0, len(votes))
for _, v := range votes {
opts := voteOptsMap[v.ID]
if opts == nil {
opts = []*db.VoteOption{}
}
exportOpts := make([]exportedVoteOption, 0, len(opts))
for _, o := range opts {
exportOpts = append(exportOpts, exportedVoteOption{
ID: o.ID,
Content: o.Content,
SortOrder: o.SortOrder,
CreatedAt: o.CreatedAt,
})
}
fields := voteFieldsMap[v.ID]
if fields == nil {
fields = []*db.VoteFormField{}
}
exportFields := make([]exportedVoteFormField, 0, len(fields))
for _, f := range fields {
exportFields = append(exportFields, exportedVoteFormField{
ID: f.ID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
CreatedAt: f.CreatedAt,
})
}
subs := voteSubsMap[v.ID]
if subs == nil {
subs = []*db.VoteSubmission{}
}
exportSubs := make([]exportedVoteSubmission, 0, len(subs))
for _, s := range subs {
exportSubs = append(exportSubs, exportedVoteSubmission{
ID: s.ID,
OptionID: s.OptionID,
VoterIP: s.VoterIP,
FormData: s.FormData,
CustomText: s.CustomText,
CreatedAt: s.CreatedAt,
})
}
exportVotes = append(exportVotes, exportedVote{
ID: v.ID,
Title: v.Title,
Description: v.Description,
Type: v.Type,
Status: v.Status,
Visibility: v.Visibility,
VoterScope: v.VoterScope,
ShowResult: v.ShowResult,
AllowCustom: v.AllowCustom,
MaxChoices: v.MaxChoices,
StartAt: v.StartAt,
EndAt: v.EndAt,
MaxVotesPerFingerprint: v.MaxVotesPerFingerprint,
RequireCaptcha: v.RequireCaptcha,
QuotaLimit: v.QuotaLimit,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
Options: exportOpts,
FormFields: exportFields,
Submissions: exportSubs,
})
}
payload := map[string]interface{}{
"exported_at": time.Now().Format(time.RFC3339),
"version": 1,
"version": 2,
"user": user,
"questions": exportQuestions,
"conversations": exportConvs,
"votes": exportVotes,
}
fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405"))