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) }