From b1db1efeb83a6d64e10e1ea8f132d4e638b40910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Sat, 9 May 2026 16:23:59 +0800 Subject: [PATCH] feat: enhance profile editing and image upload functionality - Introduced a utility function to ensure absolute media URLs for user profile images. - Updated the user cache synchronization method to handle profile data more effectively, including avatar, nickname, phone, and MBTI. - Refactored the avatar upload process to utilize the new URL handling function, improving reliability. - Modified the super article editor to focus on image uploads instead of file uploads, enhancing user experience with clearer labeling and prompts. This update aims to streamline user profile management and improve the image upload experience in the application. --- .../pages/profile-edit/profile-edit.js | 71 +++++++++++++++---- .../super-article-editor.js | 43 ++--------- .../super-article-editor.wxml | 8 +-- miniprogram/utils/miniprogramUpload.js | 2 +- soul-api/internal/config/config.go | 2 +- soul-api/internal/handler/upload.go | 23 +----- soul-api/internal/handler/upload_content.go | 41 +---------- 7 files changed, 73 insertions(+), 117 deletions(-) diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 91190a14..5c9cee82 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -14,6 +14,15 @@ const { uploadByApi } = require('../../utils/miniprogramUpload.js') const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP'] +/** 补齐小程序可用的绝对 URL(本地/OSS 相对路径拼 baseUrl) */ +function ensureAbsMediaUrl(u, base) { + const s = String(u || '').trim() + if (!s) return '' + if (/^https?:\/\//i.test(s)) return s + const b = String(base || '').replace(/\/$/, '') + return b ? b + (s.startsWith('/') ? s : '/' + s) : s +} + /** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */ const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done' @@ -409,6 +418,49 @@ Page({ } }, + /** 将接口返回的 profile data 写入 globalData.userInfo、storage;可选同步本页展示字段(头像上传时用) */ + _syncUserCacheFromProfileData(d, patchPage) { + if (!d || typeof d !== 'object') return + const base = app.globalData.baseUrl || '' + const ensureAbs = (u) => ensureAbsMediaUrl(u, base) + + if (patchPage) { + const patch = {} + if (d.avatar != null && String(d.avatar).trim() !== '') patch.avatar = ensureAbs(d.avatar) + if (d.nickname != null) patch.nickname = String(d.nickname) + if (d.phone != null) patch.phone = String(d.phone) + if (d.wechatId != null) patch.wechatId = String(d.wechatId) + if (d.mbti != null) { + patch.mbti = String(d.mbti) + const idx = MBTI_OPTIONS.indexOf(patch.mbti) + if (idx >= 0) patch.mbtiIndex = idx + } + if (Object.keys(patch).length) { + this.setData(patch, () => this._syncAvatarPreview()) + } else { + this._syncAvatarPreview() + } + } + + const u = app.globalData.userInfo + if (!u) return + if (d.nickname != null && String(d.nickname).trim() !== '') u.nickname = String(d.nickname).trim() + if (d.avatar != null && String(d.avatar).trim() !== '') u.avatar = ensureAbs(d.avatar) + if (d.phone != null && String(d.phone).trim() !== '') { + u.phone = String(d.phone).trim() + wx.setStorageSync('user_phone', u.phone) + } + if (d.wechatId != null) { + const w = String(d.wechatId).trim() + u.wechatId = w + u.wechat = w + u.wechat_id = w + if (w) wx.setStorageSync('user_wechat', w) + } + if (d.mbti != null && String(d.mbti).trim() !== '') u.mbti = String(d.mbti).trim() + wx.setStorageSync('userInfo', u) + }, + /** 点击头像:相册/相机选图(不使用 open-type=chooseAvatar,避免 Windows 开发者工具 http://tmp 渲染报错) */ onTapPickAvatar() { const run = () => { @@ -437,20 +489,20 @@ Page({ const uploadRes = await uploadByApi({ filePath, folder: 'avatars' }) let avatarUrl = uploadRes.data?.url || uploadRes.url - if (avatarUrl && !avatarUrl.startsWith('http')) { - avatarUrl = app.globalData.baseUrl + avatarUrl + if (avatarUrl && !/^https?:\/\//i.test(avatarUrl)) { + avatarUrl = ensureAbsMediaUrl(avatarUrl, app.globalData.baseUrl) } this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview()) const avatarToSave = toAvatarPath(avatarUrl) - await app.request({ + const profileRes = await app.request({ url: '/api/miniprogram/user/profile', method: 'POST', data: { userId: app.globalData.userInfo?.id, avatar: avatarToSave }, }) - if (app.globalData.userInfo) { - app.globalData.userInfo.avatar = avatarUrl - wx.setStorageSync('userInfo', app.globalData.userInfo) + if (!profileRes?.success) { + throw new Error(profileRes?.error || profileRes?.message || '保存头像失败') } + this._syncUserCacheFromProfileData(profileRes.data, true) wx.hideLoading() wx.showToast({ title: '头像已更新', icon: 'success' }) setTimeout(() => this.generateShareCard(), 200) @@ -523,12 +575,7 @@ Page({ data: payload, }) wx.showToast({ title: '保存成功', icon: 'success' }) - if (app.globalData.userInfo) { - if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname - if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar - if (payload.phone) app.globalData.userInfo.phone = payload.phone - wx.setStorageSync('userInfo', app.globalData.userInfo) - } + if (res?.data) this._syncUserCacheFromProfileData(res.data, false) if (wizardComplete) { wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1') this.setData({ wizardMode: false, saving: false }) diff --git a/miniprogram/pages/super-article-editor/super-article-editor.js b/miniprogram/pages/super-article-editor/super-article-editor.js index b39def96..c41a267a 100644 --- a/miniprogram/pages/super-article-editor/super-article-editor.js +++ b/miniprogram/pages/super-article-editor/super-article-editor.js @@ -99,49 +99,16 @@ Page({ this.setData({ content: `${this.data.content}#链接(https://)` }) }, - chooseReferenceFiles() { + /** 参考素材:仅从相册/相机选图片并上传(AI 侧按图理解) */ + chooseReferenceImages() { this.syncAuditMode() if (this.data.auditMode || app.globalData.auditMode) { wx.showToast({ title: '审核模式下不可用', icon: 'none' }) return } - if (typeof wx.chooseMessageFile !== 'function') { - wx.showModal({ - title: '添加参考', - content: '当前环境不支持从聊天记录选文件,可从相册选择图片作为参考。', - confirmText: '选图片', - cancelText: '取消', - success: (r) => { - if (r.confirm) this._pickReferenceImagesFromAlbum() - }, - }) - return - } - wx.chooseMessageFile({ - count: 15, - type: 'all', - success: (res) => { - this.handlePickedFiles(res.tempFiles || []) - }, - fail: (err) => { - const em = String((err && err.errMsg) || '') - if (/cancel|取消/i.test(em)) return - console.warn('[super-article-editor] chooseMessageFile fail', err) - wx.showModal({ - title: '无法打开会话文件', - content: - '开发者工具常不支持此能力,请用真机重试;或改用相册图片作为参考。', - confirmText: '相册图片', - cancelText: '取消', - success: (r) => { - if (r.confirm) this._pickReferenceImagesFromAlbum() - }, - }) - }, - }) + this._pickReferenceImagesFromAlbum() }, - /** 相册图片兜底(路径结构与 chooseMessageFile.tempFiles 对齐) */ _pickReferenceImagesFromAlbum() { const mapFromChooseMedia = (res) => { const files = res.tempFiles || [] @@ -296,7 +263,7 @@ Page({ } if (this.data.attachedFiles.some((x) => x.uploading)) { - wx.showToast({ title: '请等待文件上传完成', icon: 'none' }) + wx.showToast({ title: '请等待图片上传完成', icon: 'none' }) return } @@ -322,7 +289,7 @@ Page({ .map((x) => x.url) if (!prompt && !referenceText && materialUrls.length === 0 && imageUrls.length === 0) { - wx.showToast({ title: '请填写提示词或添加参考文件', icon: 'none' }) + wx.showToast({ title: '请填写提示词或添加参考图', icon: 'none' }) return } diff --git a/miniprogram/pages/super-article-editor/super-article-editor.wxml b/miniprogram/pages/super-article-editor/super-article-editor.wxml index a4384803..8745ddca 100644 --- a/miniprogram/pages/super-article-editor/super-article-editor.wxml +++ b/miniprogram/pages/super-article-editor/super-article-editor.wxml @@ -8,12 +8,12 @@ - + AI 写文章 - 参考文件(可多选) + 参考图片(可多选) - + 添加文件 + + 添加图片 @@ -25,7 +25,7 @@ 移除 - 可选图片、TXT/MD/JSON 等文本;PDF/Office 将作为附件上传,服务端尽量抽取纯文本。单文件不超 30MB。会话文件请在真机微信内选取;开发者工具若无效可改用相册图片。 + 从相册或相机添加参考图,可多选(单次最多 9 张),上传后参与 AI 生成。单张建议不超 30MB。 提示词 diff --git a/miniprogram/utils/miniprogramUpload.js b/miniprogram/utils/miniprogramUpload.js index 4179b913..eac94d5f 100644 --- a/miniprogram/utils/miniprogramUpload.js +++ b/miniprogram/utils/miniprogramUpload.js @@ -3,7 +3,7 @@ * * 必须调用 soul-api:`POST /api/miniprogram/upload`(multipart:file + folder)。 * 禁止在小程序内配置 OSS AK/SK 或直传阿里云;对象存储由服务端按 - * 「管理端 → 系统设置 → OSS」中的 oss_config 写入(未配置或失败时可落本地,以后端为准)。 + * 「管理端 → 系统设置 → OSS」中的 oss_config 优先写 OSS;未配置或失败时服务端自动落本地 uploads/。 * * @param {{ filePath: string, folder?: string, fileFieldName?: string }} opts * @returns {Promise<{ success: boolean, url?: string, data?: { url?: string } }>} diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go index e9ead40e..0cd96faf 100644 --- a/soul-api/internal/config/config.go +++ b/soul-api/internal/config/config.go @@ -58,7 +58,7 @@ type Config struct { // 上传目录(绝对路径,air 运行时避免相对路径解析错误) UploadDir string - // UploadAllowLocalFallback 为 true 时,OSS 未配置或上传失败则落盘并用 API_BASE_URL 拼接 URL(仅建议本机调试) + // UploadAllowLocalFallback 历史环境变量 UPLOAD_ALLOW_LOCAL_FALLBACK:上传路由已默认在未配置/失败时落本地,该字段不再参与 handler 分支(保留读取以免外部依赖 cfg 字段缺失)。 UploadAllowLocalFallback bool // 阿里云 OSS(可选):当库中 oss_config 的密钥为空或为占位符时兜底;与 LoadConfig 合并后再连 OSS、并在 GET /api/admin/settings 中回显生效值 diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go index 29368033..2c0bedc6 100644 --- a/soul-api/internal/handler/upload.go +++ b/soul-api/internal/handler/upload.go @@ -73,8 +73,7 @@ func isAllowedAttachment(file *multipart.FileHeader, ct string) bool { // UploadPost POST /api/upload 通用上传(multipart:file + folder) // 同源挂载:POST /api/miniprogram/upload、POST /api/admin/upload(管理端须 Admin JWT) -// 默认仅写入阿里云 OSS 并返回 OSS 公网完整 URL(见 system_config.oss_config)。 -// 若需本机无 OSS 调试,可在环境变量设置 UPLOAD_ALLOW_LOCAL_FALLBACK=true,此时未配置或失败才落盘并用 API_BASE_URL 拼接。 +// 优先写入 OSS(system_config.oss_config);未配置或上传失败时自动落本地 uploads/ 并由 BaseURL 拼接可访问 URL(不再向前端返回 OSS 配置类错误)。 func UploadPost(c *gin.Context) { file, err := c.FormFile("file") if err != nil { @@ -131,16 +130,7 @@ func UploadPost(c *gin.Context) { name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext) objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name)) - allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback - if !oss.IsEnabled() { - if !allowLocal { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config(AccessKey Secret 不得为占位符)。默认仅支持上传到 OSS 并返回 OSS 公网地址。", - }) - return - } uploadPostSaveLocal(c, file, folder, name, ct) return } @@ -169,17 +159,6 @@ func UploadPost(c *gin.Context) { return } - if !allowLocal { - msg := "上传至阿里云 OSS 失败,请检查 oss_config、Bucket 权限与网络" - if uploadErr != nil { - msg = "上传至 OSS 失败: " + uploadErr.Error() - } else if url == "" { - msg = "上传至 OSS 未返回公网 URL,请检查 endpoint、bucket 与可选 publicBaseUrl" - } - c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg}) - return - } - if uploadErr != nil { log.Printf("upload: OSS failed objectKey=%s (local fallback): %v", objectKey, uploadErr) } else { diff --git a/soul-api/internal/handler/upload_content.go b/soul-api/internal/handler/upload_content.go index b03b083c..edca0143 100644 --- a/soul-api/internal/handler/upload_content.go +++ b/soul-api/internal/handler/upload_content.go @@ -19,7 +19,6 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "soul-api/internal/config" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/oss" @@ -42,7 +41,7 @@ var ( ) // UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩) -// 默认仅写入 OSS 并返回公网完整 URL;环境变量 UPLOAD_ALLOW_LOCAL_FALLBACK=true 时未配置或失败才落盘。 +// 优先 OSS;未配置或失败时自动落本地 uploads/。 // 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85) func UploadImagePost(c *gin.Context) { file, err := c.FormFile("file") @@ -116,7 +115,6 @@ func UploadImagePost(c *gin.Context) { finalData = data } - allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback var ossURL string var ossErr error if oss.IsEnabled() { @@ -135,18 +133,6 @@ func UploadImagePost(c *gin.Context) { }) return } - if !allowLocal { - msg := "未配置阿里云 OSS 或上传失败" - if oss.IsEnabled() && ossErr != nil { - msg = "上传至 OSS 失败: " + ossErr.Error() - } else if !oss.IsEnabled() { - msg = "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config" - } else { - msg = "上传至 OSS 未返回公网地址" - } - c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg}) - return - } if ossErr != nil { log.Printf("upload/image: OSS failed folder=%s name=%s (local fallback): %v", folder, name, ossErr) } else { @@ -169,7 +155,7 @@ func UploadImagePost(c *gin.Context) { } // UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传 -// 默认仅 OSS;UPLOAD_ALLOW_LOCAL_FALLBACK=true 时未配置或失败才落盘。 +// 优先 OSS;未配置或失败时自动落本地。 // 表单:file(必填), folder(可选,默认 videos) func UploadVideoPost(c *gin.Context) { file, err := c.FormFile("file") @@ -196,30 +182,18 @@ func UploadVideoPost(c *gin.Context) { } name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext) - allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback var ossURL string var ossErr error if oss.IsEnabled() { src, openErr := file.Open() if openErr != nil { - if !allowLocal { - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"}) - return - } ossErr = openErr } else { ossURL, ossErr = ossUploadFile(src, folder, name) _ = src.Close() } } else { - if !allowLocal { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config", - }) - return - } ossErr = fmt.Errorf("OSS 未配置") } @@ -235,17 +209,6 @@ func UploadVideoPost(c *gin.Context) { return } - if !allowLocal { - msg := "上传至 OSS 失败" - if ossErr != nil { - msg = msg + ": " + ossErr.Error() - } else { - msg = "上传至 OSS 未返回公网地址" - } - c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg}) - return - } - if ossErr != nil { log.Printf("upload/video: OSS skipped or failed (local fallback) folder=%s name=%s: %v", folder, name, ossErr) }