安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性
This commit is contained in:
+220
-5
@@ -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"))
|
||||
|
||||
+22
-41
@@ -626,33 +626,15 @@ func VoteCreate(ctx context.Context) {
|
||||
}
|
||||
form.opts.CensorMetadata = form.censorMetadata
|
||||
|
||||
vote, err := db.Votes.Create(ctx.Request().Context(), form.opts)
|
||||
// 事务化创建:投票 + 选项 + 表单字段。任一步失败整体回滚,
|
||||
// 避免留下「有投票无选项」或「有选项无字段」的半成品。
|
||||
vote, err := db.Votes.CreateWithOptions(ctx.Request().Context(), form.opts, form.options, form.formFields)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote")
|
||||
renderVoteFormWithInput(ctx, false, 0, "", true)
|
||||
return
|
||||
}
|
||||
|
||||
if len(form.options) > 0 {
|
||||
if _, err := db.VoteOptions.BulkCreate(ctx.Request().Context(), vote.ID, form.options); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote options")
|
||||
_ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录
|
||||
renderVoteFormWithInput(ctx, false, 0, "", true)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(form.formFields) > 0 {
|
||||
for i := range form.formFields {
|
||||
form.formFields[i].VoteID = vote.ID
|
||||
}
|
||||
if _, err := db.VoteFormFields.BulkCreate(ctx.Request().Context(), vote.ID, form.formFields); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote form fields")
|
||||
_ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录
|
||||
renderVoteFormWithInput(ctx, false, 0, "", true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
|
||||
|
||||
if action == "publish" {
|
||||
@@ -704,10 +686,22 @@ func VoteEdit(ctx context.Context) {
|
||||
|
||||
// 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入 <script>,
|
||||
// 避免 template.HTML 在 script 上下文被转义导致初始化失效。
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
options, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote options")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
return
|
||||
}
|
||||
ctx.Data["InitialOptions"] = optionsContent(options)
|
||||
|
||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote form fields")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
return
|
||||
}
|
||||
ctx.Data["InitialFields"] = fieldsContent(fields)
|
||||
|
||||
// 时间回填:Go 模板直接输出 time.Time 不符合 datetime-local 格式,预先格式化
|
||||
@@ -829,7 +823,9 @@ func VoteUpdate(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
|
||||
// 事务化更新:投票元信息 + 替换选项 + 替换表单字段。任一步失败整体回滚,
|
||||
// 避免「元信息已更新、字段只更新一部分」的不一致草稿。
|
||||
if err := db.Votes.UpdateWithOptions(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
|
||||
Title: form.opts.Title,
|
||||
Description: form.opts.Description,
|
||||
Type: &form.opts.Type,
|
||||
@@ -844,27 +840,12 @@ func VoteUpdate(ctx context.Context) {
|
||||
RequireCaptcha: form.opts.RequireCaptcha,
|
||||
CensorMetadata: form.censorMetadata,
|
||||
QuotaLimit: form.opts.QuotaLimit,
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote")
|
||||
}, form.options, form.formFields); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote with options")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, "", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 事务重建选项与表单字段(草稿阶段无历史数据,删除重建安全)
|
||||
if err := db.VoteOptions.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.options); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote options")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.optionUpdateFailed"), false)
|
||||
return
|
||||
}
|
||||
for i := range form.formFields {
|
||||
form.formFields[i].VoteID = vote.ID
|
||||
}
|
||||
if err := db.VoteFormFields.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.formFields); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote form fields")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.fieldUpdateFailed"), false)
|
||||
return
|
||||
}
|
||||
|
||||
// 选项/字段已重建,结果聚合可能变化:主动失效该投票的结果缓存。
|
||||
votepkg.InvalidateResultsCache(vote.ID)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user