Files
tamabox/route/user/profile.go
T
2026-10-19 15:23:11 +08:00

650 lines
23 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 user
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"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"
"github.com/tamakyi/TamaBox/internal/theme"
)
// 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"))
// 处置期内审核严格度被强制为最高档且不可修改(见 moderation.EffectiveStrictness),
// 设置页需要把下拉和保存按钮禁用,否则箱主会以为自己的修改生效了。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID); err == nil && level != "" {
ctx.Data["StrictnessLocked"] = true
}
ctx.Data["TagLimits"] = tagLimits()
// 主题:可选清单(顺序同 [theme] available)+ 当前选择。
// 当前值取原始存储值,非法/已下线主题归零为「跟随站点默认」,避免单选一个都不选中。
ctx.Data["Themes"] = theme.List()
if theme.IsAvailable(ctx.User.Theme) {
ctx.Data["CurrentTheme"] = strings.ToLower(strings.TrimSpace(ctx.User.Theme))
} else {
ctx.Data["CurrentTheme"] = ""
}
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,
}
}
// UpdateTheme 保存箱主选择的主题。只写 users.theme 一列,
// 与背景图/颜色等个性化设置互不影响(各自独立的表单入口)。
func UpdateTheme(ctx context.Context, f form.UpdateProfileTheme) {
if ctx.HasError() {
ctx.Success("user/profile")
return
}
slug := strings.ToLower(strings.TrimSpace(f.Theme))
// 白名单校验:既挡住拼写错误,也挡住把任意字符串拼进 <link href> 的路径穿越尝试。
// 用 flash + 重定向而不是原地渲染:本页数据由 Profile() 装配(可选主题清单等),
// 直接 Success 会渲染出缺数据的页面。
if slug != "" && !theme.IsAvailable(slug) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.theme.invalid"))
ctx.Redirect("/user/profile")
return
}
if err := db.Users.UpdateTheme(ctx.Request().Context(), ctx.User.ID, slug); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update theme")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/profile")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.theme.saved"))
ctx.Redirect("/user/profile")
}
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.SetError(err, f)
ctx.Success("user/profile")
return
}
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.avatarRatio")), f)
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.SetError(err, f)
ctx.Success("user/profile")
return
}
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundRatio")), f)
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.SetError(err, f)
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")
}