607 lines
21 KiB
Go
607 lines
21 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 user
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/xuri/excelize/v2"
|
||
|
||
"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/storage"
|
||
)
|
||
|
||
// Profile 账号设置页。按面板分区:
|
||
// - account:用户设置(个人信息 / 个性化 / 社交 / 密码 / 导出 / 停用账号);
|
||
// - question / chat / vote:三个功能的管理(开关 + 定时 + 定量 + 各自的专属设置)。
|
||
//
|
||
// 面板由 ?tab= 指定,非法值回落到用户设置,保证分享链接不会渲染出空页面。
|
||
func Profile(ctx context.Context) {
|
||
ctx.Data["HasPagePassword"] = ctx.User.PagePassword != ""
|
||
ctx.Data["HasAskPassword"] = ctx.User.AskPassword != ""
|
||
ctx.Data["ActiveProfileTab"] = ProfileTab(ctx.Query("tab"))
|
||
ctx.Data["TagLimits"] = tagLimits()
|
||
ctx.Data["ChatLimits"] = chatLimits()
|
||
InjectFeatureViews(ctx)
|
||
ctx.Success("user/profile")
|
||
}
|
||
|
||
// tagLimits 标签与屏蔽词的数量 / 长度上限,供前端标签块输入组件做实时提示。
|
||
// 数值来自 conf.Limits([limits] 配置段),后端校验引用同一份配置。
|
||
func tagLimits() map[string]int {
|
||
return map[string]int{
|
||
"MaxTags": conf.Limits.MaxTags,
|
||
"MaxTagLen": conf.Limits.MaxTagLen,
|
||
"MaxBlockWords": conf.Limits.MaxBlockWords,
|
||
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
|
||
}
|
||
}
|
||
|
||
// chatLimits 私聊面板的数值上下界,供设置表单的 number 输入框做 min/max 提示。
|
||
// 同样来自 conf.Limits:会话条数硬顶与每日条数可设上限。
|
||
func chatLimits() map[string]int {
|
||
return map[string]int{
|
||
"MaxChatMessages": conf.Limits.ChatMaxMessages,
|
||
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
|
||
}
|
||
}
|
||
|
||
func ProfileAPI(ctx context.Context) error {
|
||
return ctx.JSON(ctx.User)
|
||
}
|
||
|
||
func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
|
||
if ctx.HasError() {
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
|
||
// 获取当前用户信息,用于保留未修改的字段
|
||
currentUser, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get current user")
|
||
ctx.SetInternalErrorFlash()
|
||
ctx.Redirect("/user/profile")
|
||
return
|
||
}
|
||
|
||
// 处理头像上传(强制 1:1,见 storage.UploadAvatarToS3)
|
||
avatarURL := currentUser.Avatar // 默认使用原值
|
||
avatarFile, avatarFileHeader, err := ctx.Request().FormFile("avatar")
|
||
if err == nil {
|
||
defer avatarFile.Close()
|
||
if avatarFileHeader.Size > storage.MaxAvatarSize {
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.avatarTooLarge")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
uploadedURL, err := storage.UploadAvatarToS3(avatarFile, avatarFileHeader)
|
||
if err != nil {
|
||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||
ctx.SetErrorFlash(err.Error())
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.avatarRatio"))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload avatar")
|
||
} else {
|
||
avatarURL = uploadedURL
|
||
}
|
||
}
|
||
|
||
// 从表单读取 UseCustomQRColor 并转换为布尔值
|
||
useCustomQRColor := f.UseCustomQRColor == "on"
|
||
|
||
// 处理背景上传(强制 16:9,见 storage.UploadBackgroundToS3)
|
||
backgroundURL := currentUser.Background // 默认使用原值
|
||
backgroundFile, backgroundFileHeader, err := ctx.Request().FormFile("background")
|
||
if err == nil {
|
||
defer backgroundFile.Close()
|
||
if backgroundFileHeader.Size > storage.MaxBackgroundSize {
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
uploadedURL, err := storage.UploadBackgroundToS3(backgroundFile, backgroundFileHeader)
|
||
if err != nil {
|
||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||
ctx.SetErrorFlash(err.Error())
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.backgroundRatio"))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload background")
|
||
} else {
|
||
backgroundURL = uploadedURL
|
||
}
|
||
}
|
||
|
||
// 处理二维码背景图上传
|
||
backgroundImageURL := currentUser.BackgroundImage // 默认使用原值
|
||
backgroundimageFile, backgroundimageFileHeader, err := ctx.Request().FormFile("backgroundimage")
|
||
if err == nil {
|
||
defer backgroundimageFile.Close()
|
||
if backgroundimageFileHeader.Size > storage.MaxBackgroundSize {
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
uploadedURL, err := storage.UploadPictureToS3(backgroundimageFile, backgroundimageFileHeader)
|
||
if err != nil {
|
||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||
ctx.SetErrorFlash(err.Error())
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload QRbackgroundImage")
|
||
} else {
|
||
backgroundImageURL = uploadedURL
|
||
}
|
||
}
|
||
|
||
// 密码修改逻辑(新密码非空时强制至少 8 位)。
|
||
// 是否真的改了密码会决定保存成功后的去向:改密则清会话登出(见文件尾部保存分支)。
|
||
passwordChanged := false
|
||
if f.NewPassword != "" {
|
||
if len(f.NewPassword) < 8 {
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.newPasswordTooShort")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
if err := db.Users.ChangePassword(ctx.Request().Context(), ctx.User.ID, f.OldPassword, f.NewPassword); err != nil {
|
||
if errors.Is(err, db.ErrBadCredential) {
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.badOldPassword")))
|
||
} else {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update password")
|
||
ctx.SetInternalError()
|
||
}
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
passwordChanged = true
|
||
}
|
||
|
||
// 通知类型
|
||
var notify db.NotifyType
|
||
if f.NotifyEmail != "" {
|
||
notify = db.NotifyTypeEmail
|
||
} else {
|
||
notify = db.NotifyTypeNone
|
||
}
|
||
|
||
// 构建更新选项
|
||
if err := db.Users.Update(ctx.Request().Context(), ctx.User.ID, db.UpdateUserOptions{
|
||
Name: f.Name,
|
||
Avatar: avatarURL,
|
||
Background: backgroundURL,
|
||
Intro: f.Intro,
|
||
Qrcodebackcolor: f.Qrcodebackcolor,
|
||
Qrcodecolor: f.Qrcodecolor,
|
||
Dotscale: f.Dotscale,
|
||
BackgroundImage: backgroundImageURL,
|
||
Backgroundimagealpha: f.Backgroundimagealpha,
|
||
Usernamecolor: f.Usernamecolor,
|
||
Introcolor: f.Introcolor,
|
||
Qrcodepdpcolor: f.Qrcodepdpcolor,
|
||
Acfunlink: f.Acfunlink,
|
||
Bililink: f.Bililink,
|
||
Weibolink: f.Weibolink,
|
||
Xiaohongshulink: f.Xiaohongshulink,
|
||
Bloglink: f.Bloglink,
|
||
UseCustomQRColor: useCustomQRColor,
|
||
Notify: notify,
|
||
Language: i18n.Resolve(f.Language),
|
||
}); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
|
||
if passwordChanged {
|
||
// 密码已落库且版本号已 +1:当前会话在下次请求即失效,
|
||
// 主动清会话并给出明确提示后回登录页,避免用户被「请先登录」含糊文案打断。
|
||
ctx.Session.Flush()
|
||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
|
||
ctx.Redirect("/login")
|
||
return
|
||
}
|
||
ctx.SetInternalErrorFlash()
|
||
} else if passwordChanged {
|
||
// 密码修改成功且其余资料也保存成功:强制登出重登。
|
||
// 先 Flush 清会话再写入 flash,保证提示能带到登录页显示。
|
||
ctx.Session.Flush()
|
||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
|
||
ctx.Redirect("/login")
|
||
return
|
||
} else {
|
||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.profileUpdated"))
|
||
}
|
||
ctx.Redirect("/user/profile")
|
||
}
|
||
|
||
// 更新私聊设置(手动读取表单,不使用 form.Bind)
|
||
//
|
||
// 注意:私聊的「开启 / 关闭」由账号设置页「私聊」面板统一控制(feature_controls,含定时与定量),
|
||
// 此处不再从表单读取开关值——否则移除复选框后每次保存都会把开关重置为关闭。
|
||
// users.allow_chat 仍按功能管理的当前值回写,保持两处一致。
|
||
func UpdateChatSettings(ctx context.Context) {
|
||
maxMessagesStr := ctx.Request().FormValue("chat_max_messages")
|
||
dailyLimitStr := ctx.Request().FormValue("chat_daily_limit")
|
||
|
||
maxMessages, err := strconv.Atoi(maxMessagesStr)
|
||
if err != nil || maxMessages < 1 {
|
||
maxMessages = 1
|
||
}
|
||
// 会话条数上限:用户只能在自己的设置里取 1..系统硬顶(conf.Limits.ChatMaxMessages)
|
||
if maxMessages > conf.Limits.ChatMaxMessages {
|
||
maxMessages = conf.Limits.ChatMaxMessages
|
||
}
|
||
|
||
dailyLimit, _ := strconv.Atoi(dailyLimitStr)
|
||
if dailyLimit < 0 {
|
||
dailyLimit = 0
|
||
}
|
||
// 每日条数:0 = 不限制;否则取 1..conf.Limits.DailyMessageLimitMax
|
||
if dailyLimit > conf.Limits.DailyMessageLimitMax {
|
||
dailyLimit = conf.Limits.DailyMessageLimitMax
|
||
}
|
||
|
||
allowChat := db.FeatureOpen(ctx.Request().Context(), ctx.User.ID, db.FeatureChat)
|
||
if err := db.Users.UpdateChatSettings(ctx.Request().Context(), ctx.User.ID, allowChat, maxMessages); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update chat settings")
|
||
ctx.SetInternalErrorFlash()
|
||
} else {
|
||
// 更新每日限制
|
||
if err := db.Users.UpdateDailyMessageLimit(ctx.Request().Context(), ctx.User.ID, dailyLimit); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update daily limit")
|
||
}
|
||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.chatUpdated"))
|
||
}
|
||
ctx.Redirect(profileURLWithTab(ProfileTabChat))
|
||
}
|
||
|
||
func ExportProfile(ctx context.Context) {
|
||
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
|
||
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errUser")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
|
||
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
|
||
FilterAnswered: false,
|
||
ShowPrivate: true,
|
||
})
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions")
|
||
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errQuestion")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
|
||
f, err := createExportExcelFile(user, questions)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create excel file")
|
||
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errCreateExcel")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
|
||
fileName := fmt.Sprintf("狼的提问箱账号信息导出-%s-%s.xlsx", user.Domain, time.Now().Format("20060102150405"))
|
||
ctx.ResponseWriter().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
|
||
|
||
if err := f.Write(ctx.ResponseWriter()); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to write excel file")
|
||
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errWriteExcel")))
|
||
ctx.Success("user/profile")
|
||
return
|
||
}
|
||
}
|
||
|
||
func createXLSXStreamWriter(xlsx *excelize.File, sheet string, headers []string) (*excelize.StreamWriter, error) {
|
||
xlsx.NewSheet(sheet)
|
||
sw, err := xlsx.NewStreamWriter(sheet)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "new stream writer")
|
||
}
|
||
|
||
cols := make([]interface{}, 0, len(headers))
|
||
for _, c := range headers {
|
||
cols = append(cols, c)
|
||
}
|
||
err = sw.SetRow("A1", cols)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "set header row")
|
||
}
|
||
return sw, nil
|
||
}
|
||
|
||
// sanitizeCell 防止表格软件公式注入:以 = + - @ (或控制字符)开头的单元格内容会在
|
||
// Excel/CSV 中被当作公式执行,可能导致数据外泄或代码执行。前置单引号可让表格软件
|
||
// 将其按纯文本处理(显示时自动隐藏该引号)。
|
||
func sanitizeCell(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
switch r[0] {
|
||
case '=', '+', '-', '@', '\t', '\r', '\n':
|
||
return "'" + s
|
||
}
|
||
return s
|
||
}
|
||
|
||
func createExportExcelFile(user *db.User, questions []*db.Question) (*excelize.File, error) {
|
||
f := excelize.NewFile()
|
||
|
||
sw, err := createXLSXStreamWriter(f, "账号信息", nil)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
|
||
}
|
||
// Set personal information sheet.
|
||
personalData := [][]interface{}{
|
||
{"狼的提问箱 账号信息导出", fmt.Sprintf("导出时间 %s", time.Now().Format("2006-01-02 15:04:05"))},
|
||
{"电子邮箱", user.Email},
|
||
{"昵称", user.Name},
|
||
{"个性域名", user.Domain},
|
||
{"AcFun主页", user.Acfunlink},
|
||
{"Bili主页", user.Bililink},
|
||
{"微博主页", user.Weibolink},
|
||
{"小红书主页", user.Xiaohongshulink},
|
||
{"个人博客主页", user.Bloglink},
|
||
{"介绍", user.Intro},
|
||
{"二维码背景色", user.Qrcodebackcolor},
|
||
{"二维码前景色", user.Qrcodecolor},
|
||
{"二维码点大小", user.Dotscale},
|
||
{"昵称颜色", user.Usernamecolor},
|
||
{"介绍颜色", user.Introcolor},
|
||
{"二维码探测图形颜色", user.Qrcodepdpcolor},
|
||
{"头像 URL", user.Avatar},
|
||
{"背景图 URL", user.Background},
|
||
{"注册时间", user.CreatedAt},
|
||
}
|
||
currentRow := 1
|
||
for _, row := range personalData {
|
||
// personalData 是 [][]interface{}:字符串需净化防公式注入,
|
||
// 非字符串(如 CreatedAt 的 time.Time)原样写入,交给 excelize 处理。
|
||
vals := make([]interface{}, len(row))
|
||
for i, v := range row {
|
||
if s, ok := v.(string); ok {
|
||
vals[i] = sanitizeCell(s)
|
||
} else {
|
||
vals[i] = v
|
||
}
|
||
}
|
||
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
|
||
_ = sw.SetRow(cell, vals)
|
||
currentRow++
|
||
}
|
||
if err := sw.Flush(); err != nil {
|
||
return nil, errors.Wrap(err, "flush personal data")
|
||
}
|
||
|
||
// Set questions sheet.
|
||
sw, err = createXLSXStreamWriter(f, "提问", []string{"提问时间", "问题", "回答"})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
|
||
}
|
||
|
||
currentRow = 2 // Include header row.
|
||
for _, question := range questions {
|
||
question := question
|
||
vals := []interface{}{question.CreatedAt, sanitizeCell(question.Content), sanitizeCell(question.Answer)}
|
||
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
|
||
_ = sw.SetRow(cell, vals)
|
||
currentRow++
|
||
}
|
||
if err := sw.Flush(); err != nil {
|
||
return nil, errors.Wrap(err, "flush personal data")
|
||
}
|
||
|
||
f.SetActiveSheet(f.GetSheetIndex("提问"))
|
||
f.DeleteSheet("Sheet1") // Delete default sheet.
|
||
|
||
return f, nil
|
||
}
|
||
|
||
func DeactivateProfile(ctx context.Context) {
|
||
ctx.Success("user/deactivate")
|
||
}
|
||
|
||
// DeactivatedProfile 账号停用成功页。停用后会话已被清空、无法再登录,
|
||
// 故为公开页,用于明确告知用户「已停用」而不是把其丢回登录页。
|
||
func DeactivatedProfile(ctx context.Context) {
|
||
ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))
|
||
ctx.Success("user/deactivated")
|
||
}
|
||
|
||
// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息),
|
||
// 符合可携带权要求,供用户备份/迁移。
|
||
func ExportProfileJSON(ctx context.Context) {
|
||
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errUser"))
|
||
ctx.Redirect("/user/profile")
|
||
return
|
||
}
|
||
|
||
// 用户提问(含私密)
|
||
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
|
||
FilterAnswered: false,
|
||
ShowPrivate: true,
|
||
})
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions for export")
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
|
||
ctx.Redirect("/user/profile")
|
||
return
|
||
}
|
||
|
||
type exportedQuestion struct {
|
||
ID uint `json:"id"`
|
||
Content string `json:"content"`
|
||
Answer string `json:"answer"`
|
||
IsPrivate bool `json:"is_private"`
|
||
IsPinned bool `json:"is_pinned"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
FollowUps []*db.FollowUp `json:"follow_ups"`
|
||
Images []string `json:"images"`
|
||
}
|
||
|
||
// 批量拉取所有提问的追问与图片(IN 查询 + 内存分组),避免逐题查询造成 N+1。
|
||
questionIDs := make([]uint, 0, len(questions))
|
||
for _, q := range questions {
|
||
questionIDs = append(questionIDs, q.ID)
|
||
}
|
||
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)
|
||
}
|
||
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)
|
||
}
|
||
|
||
exportQuestions := make([]exportedQuestion, 0, len(questions))
|
||
for _, q := range questions {
|
||
followUps := followUpMap[q.ID]
|
||
if followUps == nil {
|
||
followUps = []*db.FollowUp{}
|
||
}
|
||
imgs := imageMap[q.ID]
|
||
imageURLs := make([]string, 0, len(imgs))
|
||
for _, img := range imgs {
|
||
imageURLs = append(imageURLs, img.Key)
|
||
}
|
||
exportQuestions = append(exportQuestions, exportedQuestion{
|
||
ID: q.ID,
|
||
Content: q.Content,
|
||
Answer: q.Answer,
|
||
IsPrivate: q.IsPrivate,
|
||
IsPinned: q.IsPinned,
|
||
CreatedAt: q.CreatedAt,
|
||
UpdatedAt: q.UpdatedAt,
|
||
FollowUps: followUps,
|
||
Images: imageURLs,
|
||
})
|
||
}
|
||
|
||
// 私聊会话与消息
|
||
type exportedMessage struct {
|
||
Content string `json:"content"`
|
||
SenderID uint `json:"sender_id"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
type exportedConversation struct {
|
||
OwnerID uint `json:"owner_id"`
|
||
AskerID uint `json:"asker_id"`
|
||
IsClosed bool `json:"is_closed"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
Messages []exportedMessage `json:"messages"`
|
||
}
|
||
|
||
exportConvs := make([]exportedConversation, 0)
|
||
allConvs := make([]*db.Conversation, 0)
|
||
if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil {
|
||
allConvs = append(allConvs, owned...)
|
||
}
|
||
if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil {
|
||
allConvs = append(allConvs, asked...)
|
||
}
|
||
// 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。
|
||
seen := make(map[uint]bool)
|
||
dedupedConvs := make([]*db.Conversation, 0, len(allConvs))
|
||
convIDs := make([]uint, 0, len(allConvs))
|
||
for _, conv := range allConvs {
|
||
if seen[conv.ID] {
|
||
continue
|
||
}
|
||
seen[conv.ID] = true
|
||
dedupedConvs = append(dedupedConvs, conv)
|
||
convIDs = append(convIDs, conv.ID)
|
||
}
|
||
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)
|
||
}
|
||
for _, conv := range dedupedConvs {
|
||
msgs := msgMap[conv.ID]
|
||
if msgs == nil {
|
||
msgs = []*db.Message{}
|
||
}
|
||
exportMsgs := make([]exportedMessage, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
exportMsgs = append(exportMsgs, exportedMessage{
|
||
Content: m.Content,
|
||
SenderID: m.SenderID,
|
||
CreatedAt: m.CreatedAt,
|
||
})
|
||
}
|
||
exportConvs = append(exportConvs, exportedConversation{
|
||
OwnerID: conv.OwnerID,
|
||
AskerID: conv.AskerID,
|
||
IsClosed: conv.IsClosed,
|
||
CreatedAt: conv.CreatedAt,
|
||
Messages: exportMsgs,
|
||
})
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"exported_at": time.Now().Format(time.RFC3339),
|
||
"version": 1,
|
||
"user": user,
|
||
"questions": exportQuestions,
|
||
"conversations": exportConvs,
|
||
}
|
||
|
||
fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405"))
|
||
ctx.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
|
||
|
||
enc := json.NewEncoder(ctx.ResponseWriter())
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(payload); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode JSON export")
|
||
}
|
||
}
|
||
|
||
func DeactivateProfileAction(ctx context.Context) {
|
||
if err := db.Users.Deactivate(ctx.Request().Context(), ctx.User.ID); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to deactivate user")
|
||
ctx.SetInternalError()
|
||
ctx.Success("user/deactivate")
|
||
return
|
||
}
|
||
ctx.Session.Flush()
|
||
// 跳独立成功页而非登录页:账号已停用、无法再登录,落在登录页易让用户误以为操作未生效。
|
||
ctx.Redirect("/deactivated")
|
||
}
|