// 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 { // 密码修改成功且其余资料也保存成功:强制登出重登。 // 顺序与 DeactivateProfileAction 保持一致:先 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") } // 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.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.deactivated")) ctx.Redirect("/login") }