{{article.title}} {{article.authorNickname || '超级个体'}} · {{article.createdAtText}} + + + 复制 H5 全文链接 浏览器或微信内打开可阅读全文,方便分享 diff --git a/miniprogram/pages/super-article-detail/super-article-detail.wxss b/miniprogram/pages/super-article-detail/super-article-detail.wxss index ea0814b3..762fdea2 100644 --- a/miniprogram/pages/super-article-detail/super-article-detail.wxss +++ b/miniprogram/pages/super-article-detail/super-article-detail.wxss @@ -6,16 +6,30 @@ } .nav-back, .nav-placeholder { width: 64rpx; } .nav-title { font-size: 32rpx; font-weight: 700; } -.card { - margin: 24rpx; padding: 30rpx; border-radius: 24rpx; - background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2); +.article-body { + padding: 8rpx 24rpx 48rpx; } .title { font-size: 36rpx; font-weight: 700; line-height: 1.4; display: block; } .meta { margin-top: 12rpx; font-size: 22rpx; color: #94a3b8; display: block; } -.h5-row { margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid rgba(148, 163, 184, 0.2); } +.img-gallery { + margin-top: 20rpx; + display: flex; + flex-direction: column; + gap: 12rpx; +} +.article-gallery-img { + width: 100%; + display: block; + border-radius: 0; +} +.h5-row { margin-top: 28rpx; padding-top: 0; border-top: none; } .h5-btn { - display: inline-block; padding: 12rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 600; - color: #0ea5e9; background: rgba(14, 165, 233, 0.12); border: 1rpx solid rgba(14, 165, 233, 0.45); + display: inline-block; + padding: 12rpx 0; + font-size: 24rpx; + font-weight: 600; + color: #22d3ee; + background: transparent; } .h5-hint { display: block; margin-top: 12rpx; font-size: 20rpx; color: #64748b; line-height: 1.5; } .content { diff --git a/miniprogram/pages/super-article-editor/super-article-editor.js b/miniprogram/pages/super-article-editor/super-article-editor.js index c41a267a..c0f616b5 100644 --- a/miniprogram/pages/super-article-editor/super-article-editor.js +++ b/miniprogram/pages/super-article-editor/super-article-editor.js @@ -15,10 +15,13 @@ function newAttachId() { Page({ data: { + /** 临时隐藏「AI 写文章」卡片;需上线时再设为 true */ + showAiWriteSection: false, statusBarHeight: 44, auditMode: false, title: '', content: '', + bodyImages: [], saving: false, meNickname: '', aiPrompt: '', @@ -99,6 +102,117 @@ Page({ this.setData({ content: `${this.data.content}#链接(https://)` }) }, + /** 编辑区配图(发布后展示在正文前),最多 9 张 */ + chooseArticleImages() { + this.syncAuditMode() + if (this.data.auditMode || app.globalData.auditMode) { + wx.showToast({ title: '审核模式下不可用', icon: 'none' }) + return + } + const max = 9 + const cur = this.data.bodyImages.length + if (cur >= max) { + wx.showToast({ title: '最多 9 张配图', icon: 'none' }) + return + } + const pickCount = max - cur + const mapFromChooseMedia = (res) => { + const files = res.tempFiles || [] + return files.map((f, i) => ({ + path: f.tempFilePath, + name: `配图_${Date.now()}_${i}.jpg`, + size: f.size || 0, + })) + } + const mapFromChooseImage = (res) => { + const paths = res.tempFilePaths || [] + const t = Date.now() + return paths.map((path, i) => ({ + path, + name: `配图_${t}_${i}.jpg`, + size: 0, + })) + } + const runUpload = async (tempFiles) => { + for (const f of tempFiles) { + if (this.data.bodyImages.length >= max) break + await this._uploadOneArticleBodyImage(f) + } + } + if (typeof wx.chooseMedia === 'function') { + wx.chooseMedia({ + count: pickCount, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'], + success: (res) => { + void runUpload(mapFromChooseMedia(res)) + }, + fail: (err) => { + const em = String((err && err.errMsg) || '') + if (/cancel|取消/i.test(em)) return + wx.showToast({ title: '选择图片失败', icon: 'none' }) + }, + }) + return + } + if (typeof wx.chooseImage === 'function') { + wx.chooseImage({ + count: pickCount, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: (res) => { + void runUpload(mapFromChooseImage(res)) + }, + fail: (err) => { + const em = String((err && err.errMsg) || '') + if (/cancel|取消/i.test(em)) return + wx.showToast({ title: '选择图片失败', icon: 'none' }) + }, + }) + return + } + wx.showToast({ title: '当前环境不支持选图片', icon: 'none' }) + }, + + async _uploadOneArticleBodyImage(f) { + const filePath = f.path + const name = String(f.name || '配图').trim() || '配图' + const size = f.size || 0 + if (!filePath) return + if (size > 29 * 1024 * 1024) { + wx.showToast({ title: '单张请勿超过 30MB', icon: 'none' }) + return + } + const id = newAttachId() + this.setData({ + bodyImages: this.data.bodyImages.concat([{ id, url: '', uploading: true }]), + }) + try { + const up = await uploadByApi({ filePath, folder: 'super-article-images' }) + const url = this._pickUploadUrl(up) + if (!url) throw new Error('no url') + const next = this.data.bodyImages.map((x) => + x.id === id ? Object.assign({}, x, { url, uploading: false }) : x, + ) + this.setData({ bodyImages: next }) + } catch (_) { + this.setData({ + bodyImages: this.data.bodyImages.filter((x) => x.id !== id), + }) + wx.showToast({ title: `${name} 上传失败`, icon: 'none' }) + } + }, + + removeArticleImage(e) { + if (this.data.auditMode) return + const id = e.currentTarget.dataset.id + if (!id) return + this.setData({ + bodyImages: this.data.bodyImages.filter((x) => x.id !== id), + }) + }, + /** 参考素材:仅从相册/相机选图片并上传(AI 侧按图理解) */ chooseReferenceImages() { this.syncAuditMode() @@ -334,19 +448,30 @@ Page({ wx.showToast({ title: '请填写标题和正文', icon: 'none' }) return } + if (this.data.bodyImages.some((x) => x.uploading)) { + wx.showToast({ title: '请等待配图上传完成', icon: 'none' }) + return + } + const images = this.data.bodyImages + .map((x) => String(x.url || '').trim()) + .filter(Boolean) this.setData({ saving: true }) + wx.showLoading({ title: '发布中…', mask: true }) try { const res = await app.request({ url: '/api/miniprogram/super/articles', method: 'POST', - data: { userId, title, content }, + data: { userId, title, content, images }, }) if (!res?.success) throw new Error(res?.error || '发布失败') + this.setData({ title: '', content: '', bodyImages: [] }) wx.showToast({ title: '发布成功', icon: 'success' }) - setTimeout(() => wx.navigateBack(), 400) } catch (e) { wx.showToast({ title: e.message || '发布失败', icon: 'none' }) } finally { + try { + wx.hideLoading() + } catch (_) {} this.setData({ saving: false }) } }, diff --git a/miniprogram/pages/super-article-editor/super-article-editor.wxml b/miniprogram/pages/super-article-editor/super-article-editor.wxml index 8745ddca..efad0b87 100644 --- a/miniprogram/pages/super-article-editor/super-article-editor.wxml +++ b/miniprogram/pages/super-article-editor/super-article-editor.wxml @@ -8,8 +8,8 @@ - - + + AI 写文章 参考图片(可多选) @@ -42,6 +42,19 @@ + 配图(可选,最多 9 张) + + + 添加配图 + + + + + 上传中… + × + + + 配图会展示在正文上方(与书籍正文分离存储);单张建议不超 30MB。 + 正文 @自己 @@ -52,7 +65,7 @@ 提示:可直接输入 @昵称 或 #链接(https://xxx) - {{saving ? '发布中…' : '发布文章'}} + {{saving ? '发布中…' : '发布文章'}} diff --git a/miniprogram/pages/super-article-editor/super-article-editor.wxss b/miniprogram/pages/super-article-editor/super-article-editor.wxss index 5a97bd2c..eb79b77f 100644 --- a/miniprogram/pages/super-article-editor/super-article-editor.wxss +++ b/miniprogram/pages/super-article-editor/super-article-editor.wxss @@ -36,6 +36,54 @@ .file-remove { flex-shrink: 0; font-size: 24rpx; color: #f97316; padding: 8rpx 12rpx; } +.body-img-grid { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-bottom: 12rpx; +} +.body-img-cell { + position: relative; + width: 160rpx; + height: 160rpx; + border-radius: 14rpx; + overflow: hidden; + background: rgba(2, 6, 23, 0.65); + border: 1rpx solid rgba(148, 163, 184, 0.18); +} +.body-img-thumb { + width: 100%; + height: 100%; + display: block; +} +.body-img-mask { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 22rpx; + color: #94a3b8; + background: rgba(15, 23, 42, 0.72); +} +.body-img-remove { + position: absolute; + right: 6rpx; + top: 4rpx; + width: 44rpx; + height: 44rpx; + line-height: 40rpx; + text-align: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + background: rgba(0, 0, 0, 0.45); + border-radius: 999rpx; + z-index: 2; +} .prompt-textarea { min-height: 200rpx; } .generate-btn { margin-top: 20rpx; height: 88rpx; border-radius: 16rpx; @@ -60,6 +108,7 @@ color: #032b35; font-size: 30rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; } +.submit-btn-loading { opacity: 0.72; pointer-events: none; } /* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */ .audit-mode-mask { diff --git a/miniprogram/pages/super-moments/super-moments.js b/miniprogram/pages/super-moments/super-moments.js new file mode 100644 index 00000000..14db3178 --- /dev/null +++ b/miniprogram/pages/super-moments/super-moments.js @@ -0,0 +1,157 @@ +/** + * 动态广场:超级个体图文列表(触底分页) + */ +const app = getApp() +const { isSafeImageSrc } = require('../../utils/imageUrl.js') + +const PAGE_SIZE = 10 + +function avatarLetter(name) { + const s = String(name || '').trim() + if (!s) return '会' + const ch = s[0] + return /[\u4e00-\u9fff]/.test(ch) ? ch : '会' +} + +function relativeTime(iso) { + if (!iso) return '' + const d = new Date(iso) + const t = d.getTime() + if (Number.isNaN(t)) return '' + const sec = Math.max(0, Math.floor((Date.now() - t) / 1000)) + if (sec < 60) return '刚刚' + if (sec < 3600) return `${Math.floor(sec / 60)}分钟前` + if (sec < 86400) return `${Math.floor(sec / 3600)}小时前` + if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}天前` + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${m}-${day}` +} + +function mapFeedRows(rows) { + return (rows || []).map((r) => { + const nick = String(r.authorNickname || '').trim() || '超级个体' + const av = String(r.authorAvatar || '').trim() + const avatarOk = isSafeImageSrc(av) + const imgs = Array.isArray(r.images) ? r.images : [] + const displayImages = imgs.map((u) => String(u || '').trim()).filter((u) => isSafeImageSrc(u)) + return Object.assign({}, r, { + authorNickname: nick, + avatarSrc: av, + avatarOk, + avatarLetter: avatarLetter(nick), + timeText: relativeTime(r.createdAt), + preview: String(r.preview || '').trim(), + displayImages, + }) + }) +} + +Page({ + data: { + statusBarHeight: 44, + feedList: [], + page: 1, + total: 0, + hasMore: true, + loading: true, + loadingMore: false, + }, + + async onLoad() { + this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 }) + try { + await app.getAuditMode() + } catch (_) {} + if (app.globalData.auditMode) { + wx.switchTab({ url: '/pages/index/index' }) + return + } + this.reloadFeed() + }, + + async onShow() { + try { + await app.getAuditMode() + } catch (_) {} + if (app.globalData.auditMode) { + wx.switchTab({ url: '/pages/index/index' }) + return + } + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + const tabBar = this.getTabBar() + if (tabBar && tabBar.loadFeatureConfig) tabBar.loadFeatureConfig() + if (tabBar && tabBar.updateSelected) tabBar.updateSelected() + } + }, + + onReachBottom() { + this.loadMore() + }, + + async reloadFeed() { + this.setData({ + loading: true, + page: 1, + feedList: [], + hasMore: true, + total: 0, + }) + try { + await this.fetchPage(1, true) + } finally { + this.setData({ loading: false }) + } + }, + + async loadMore() { + if (this.data.loading || this.data.loadingMore || !this.data.hasMore) return + const next = this.data.page + 1 + this.setData({ loadingMore: true }) + try { + await this.fetchPage(next, false) + } finally { + this.setData({ loadingMore: false }) + } + }, + + async fetchPage(page, replace) { + const res = await app.request({ + url: `/api/miniprogram/super/articles/feed?page=${encodeURIComponent(String(page))}&pageSize=${PAGE_SIZE}`, + silent: true, + }) + if (!res?.success) { + wx.showToast({ title: String(res?.error || '加载失败'), icon: 'none' }) + if (replace) this.setData({ feedList: [], hasMore: false, total: 0 }) + return + } + const list = mapFeedRows(res.list || []) + const total = Number(res.total) || 0 + const merged = replace ? list : this.data.feedList.concat(list) + const hasMore = merged.length < total + this.setData({ + feedList: merged, + page, + total, + hasMore, + }) + }, + + openArticle(e) { + const id = e.currentTarget.dataset.id + if (!id) return + wx.navigateTo({ + url: `/pages/super-article-detail/super-article-detail?id=${encodeURIComponent(String(id))}`, + }) + }, + + previewArticleImages(e) { + const fi = Number(e.currentTarget.dataset.feedIndex) + const ii = Number(e.currentTarget.dataset.imgIndex) + const row = this.data.feedList[fi] + const urls = row && row.displayImages ? row.displayImages : [] + if (!urls.length) return + const current = urls[ii] || urls[0] + wx.previewImage({ urls, current }) + }, +}) diff --git a/miniprogram/pages/super-moments/super-moments.json b/miniprogram/pages/super-moments/super-moments.json new file mode 100644 index 00000000..89389a5d --- /dev/null +++ b/miniprogram/pages/super-moments/super-moments.json @@ -0,0 +1,9 @@ +{ + "usingComponents": { + "icon": "/components/icon/icon" + }, + "enablePullDownRefresh": false, + "onReachBottomDistance": 160, + "backgroundTextStyle": "light", + "backgroundColor": "#0b1220" +} diff --git a/miniprogram/pages/super-moments/super-moments.wxml b/miniprogram/pages/super-moments/super-moments.wxml new file mode 100644 index 00000000..f6074f65 --- /dev/null +++ b/miniprogram/pages/super-moments/super-moments.wxml @@ -0,0 +1,61 @@ + + + + 动态广场 + + + + + + + 暂无动态 + 超级个体发文后将出现在这里 + + + + + + + {{item.avatarLetter}} + + {{item.authorNickname || '超级个体'}} + {{item.timeText}} + + + + {{item.title}} + {{item.preview}} + + + + + + + 查看全文 + + + + + + + 加载中… + 已经到底啦 + + + diff --git a/miniprogram/pages/super-moments/super-moments.wxss b/miniprogram/pages/super-moments/super-moments.wxss new file mode 100644 index 00000000..3a380e32 --- /dev/null +++ b/miniprogram/pages/super-moments/super-moments.wxss @@ -0,0 +1,164 @@ +.page { + min-height: 100vh; + background: #0b1220; + color: #e2e8f0; + padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px)); + box-sizing: border-box; +} + +.nav-bar { + position: fixed; + left: 0; + right: 0; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 24rpx; + background: rgba(5, 11, 20, 0.92); + border-bottom: 1rpx solid rgba(148, 163, 184, 0.08); +} + +.nav-placeholder { + width: 64rpx; +} + +.nav-title { + font-size: 34rpx; + font-weight: 700; + color: #f8fafc; +} + +.feed-wrap { + padding: 8rpx 0 32rpx; +} + +.empty-block { + padding: 120rpx 32rpx; + text-align: center; +} + +.empty-text { + display: block; + font-size: 30rpx; + color: #94a3b8; +} + +.empty-sub { + display: block; + margin-top: 16rpx; + font-size: 24rpx; + color: #64748b; +} + +/* 扁平信息流:无卡片底、无描边、无阴影 */ +.feed-item { + padding: 28rpx 24rpx 36rpx; +} + +.feed-head { + display: flex; + align-items: center; + gap: 16rpx; +} + +.feed-avatar { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + flex-shrink: 0; + background: #1e293b; +} + +.feed-avatar-ph { + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 700; + color: #67e8f9; + background: rgba(30, 41, 59, 0.65); +} + +.feed-head-mid { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.feed-name { + font-size: 28rpx; + font-weight: 600; + color: #f1f5f9; +} + +.feed-time { + font-size: 22rpx; + color: #94a3b8; +} + +.feed-title { + display: block; + margin-top: 18rpx; + font-size: 30rpx; + font-weight: 700; + color: #f8fafc; + line-height: 1.45; +} + +.feed-preview { + display: block; + margin-top: 12rpx; + font-size: 26rpx; + color: #cbd5e1; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.feed-imgs { + margin-top: 16rpx; + display: flex; + flex-wrap: wrap; + gap: 10rpx; +} + +.feed-img { + width: calc((100% - 20rpx) / 3); + height: 200rpx; + border-radius: 0; +} + +.feed-img-one { + width: 100%; + height: 320rpx; +} + +.feed-foot { + margin-top: 12rpx; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4rpx; +} + +.feed-more { + font-size: 24rpx; + color: rgba(34, 211, 238, 0.85); + font-weight: 500; +} + +.feed-more-icon { + opacity: 0.85; +} + +.load-more { + text-align: center; + padding: 24rpx 0 48rpx; + font-size: 24rpx; + color: #64748b; +} diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go index 365f2bdb..29c553e7 100644 --- a/soul-api/internal/database/database.go +++ b/soul-api/internal/database/database.go @@ -64,6 +64,8 @@ func Init(dsn string) error { ensureOpenPlatformTablesRaw(db) ensureUserDiscPdpColumns(db) ensureUserPasswordHashColumn(db) + ensureSuperArticlesTableRaw(db) + ensureSuperArticleImagesColumn(db) log.Println("database: connected") return nil } @@ -154,6 +156,8 @@ func Init(dsn string) error { ensureOpenPlatformTablesRaw(db) ensureUserDiscPdpColumns(db) ensureUserPasswordHashColumn(db) + ensureSuperArticlesTableRaw(db) + ensureSuperArticleImagesColumn(db) log.Println("database: connected") return nil } @@ -235,6 +239,48 @@ CREATE TABLE IF NOT EXISTS open_platform_api_logs ( } } +// ensureSuperArticlesTableRaw SKIP_AUTO_MIGRATE 或未跑 AutoMigrate 时兜底建表(与 model.SuperArticle、scripts/create_super_articles.sql 一致) +func ensureSuperArticlesTableRaw(db *gorm.DB) { + m := db.Migrator() + if m.HasTable(&model.SuperArticle{}) { + return + } + sqlStmt := ` +CREATE TABLE IF NOT EXISTS super_articles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id VARCHAR(50) NOT NULL COMMENT '作者 users.id', + title VARCHAR(200) NOT NULL DEFAULT '', + content LONGTEXT COMMENT '正文', + images LONGTEXT NULL COMMENT '配图 URL JSON 数组', + created_at DATETIME(3) NULL, + updated_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_super_articles_user_time (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='超级个体发文(小程序 /s/:id H5)'` + if err := db.Exec(sqlStmt).Error; err != nil { + log.Printf("database: ensureSuperArticlesTableRaw warning: %v", err) + } +} + +// ensureSuperArticleImagesColumn 旧库补 images 列(SKIP_AUTO_MIGRATE / 早期建表无该列) +func ensureSuperArticleImagesColumn(db *gorm.DB) { + m := db.Migrator() + if !m.HasTable(&model.SuperArticle{}) { + return + } + if m.HasColumn(&model.SuperArticle{}, "Images") { + return + } + if err := db.Exec("ALTER TABLE super_articles ADD COLUMN images LONGTEXT NULL COMMENT '配图 URL JSON 数组'").Error; err != nil { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "duplicate column") { + return + } + log.Printf("database: super_articles add images column warning: %v", err) + } +} + func ensurePersonSchema(db *gorm.DB) { m := db.Migrator() if !m.HasColumn(&model.Person{}, "is_pinned") { diff --git a/soul-api/internal/handler/h5_super_article.go b/soul-api/internal/handler/h5_super_article.go index 9ed1a536..76e48e53 100644 --- a/soul-api/internal/handler/h5_super_article.go +++ b/soul-api/internal/handler/h5_super_article.go @@ -37,6 +37,26 @@ func h5SuperArticleBodyHTML(text string) string { return strings.ReplaceAll(src, "\n", "
") } +func h5SuperArticleGalleryHTML(urls []string) string { + if len(urls) == 0 { + return "" + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + type h5SuperArticleData struct { Title, MetaLine, ContentHTML string Canonical string @@ -111,9 +131,16 @@ func H5SuperArticlePage(c *gin.Context) { ogDesc = p } } - ogImage := strings.TrimSpace(author.Avatar) - if ogImage != "" { - ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL) + articleImages := parseSuperArticleImagesJSON(row.Images) + ogImage := "" + if len(articleImages) > 0 && strings.TrimSpace(articleImages[0]) != "" { + ogImage = h5AbsolutizeImage(strings.TrimSpace(articleImages[0]), cfg.BaseURL) + } + if ogImage == "" { + ogImage = strings.TrimSpace(author.Avatar) + if ogImage != "" { + ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL) + } } shareLinkJSON, _ := json.Marshal(canonical) signURL := h5RequestSignURL(c) @@ -131,7 +158,7 @@ func H5SuperArticlePage(c *gin.Context) { page := h5BuildSuperArticlePage(h5SuperArticleData{ Title: ti, MetaLine: html.EscapeString(metaLine), - ContentHTML: h5SuperArticleBodyHTML(row.Content), + ContentHTML: h5SuperArticleGalleryHTML(articleImages) + h5SuperArticleBodyHTML(row.Content), Canonical: canonical, OgTagsHTML: h5BuildOgTags(canonical, plainTitle, ogDesc, ogImage), ShareLinkJSON: string(shareLinkJSON), @@ -181,6 +208,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue" .hdr{padding:8px 0 20px;border-bottom:1px solid rgba(255,255,255,0.08);margin-bottom:20px} .hdr h1{font-size:22px;color:#f8fafc;line-height:1.45;font-weight:700} .hdr .meta{font-size:13px;color:rgba(148,163,184,0.95);margin-top:10px} +.sa-gallery{display:flex;flex-direction:column;gap:12px;margin-bottom:20px} +.sa-img-wrap{border-radius:12px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)} +.sa-gallery img{display:block;width:100%;height:auto} .content{font-size:16px;color:#e2e8f0;line-height:1.8;word-break:break-word} .cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(11,18,32,0.98) 45%,transparent);padding:10px 16px calc(16px + env(safe-area-inset-bottom));z-index:100} .cta-inner{max-width:680px;margin:0 auto} diff --git a/soul-api/internal/handler/miniprogram_super_articles.go b/soul-api/internal/handler/miniprogram_super_articles.go index 56d5b9eb..3cdef75b 100644 --- a/soul-api/internal/handler/miniprogram_super_articles.go +++ b/soul-api/internal/handler/miniprogram_super_articles.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "strconv" "strings" @@ -9,6 +10,7 @@ import ( "soul-api/internal/model" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type superArticleAuthorRow struct { @@ -35,13 +37,163 @@ func loadSuperArticleAuthorMap(ids []string) map[string]superArticleAuthorRow { return out } +const ( + superArticleMaxImages = 9 + superArticleMaxImageURLLen = 2048 +) + +func parseSuperArticleImagesJSON(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return nil + } + out := make([]string, 0, len(arr)) + for _, u := range arr { + u = strings.TrimSpace(u) + if u != "" { + out = append(out, u) + } + } + return out +} + +// superArticleFeedAllowedAuthorIDs 文章中出现过、且当前仍为超级个体的用户 ID(用于动态广场) +func superArticleFeedAllowedAuthorIDs(db *gorm.DB) []string { + var raw []string + _ = db.Model(&model.SuperArticle{}).Distinct("user_id").Pluck("user_id", &raw).Error + out := make([]string, 0, len(raw)) + seen := map[string]bool{} + for _, uid := range raw { + uid = strings.TrimSpace(uid) + if uid == "" || seen[uid] { + continue + } + seen[uid] = true + if _, ok := miniprogramSuperIndividualPerson(db, uid); ok { + out = append(out, uid) + } + } + return out +} + +// MiniprogramSuperArticleFeed GET /api/miniprogram/super/articles/feed?page=&pageSize= +// 动态广场:当前仍为超级个体的作者所发文章,按创建时间倒序分页。 +func MiniprogramSuperArticleFeed(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 30 { + pageSize = 10 + } + + db := database.DB() + allowed := superArticleFeedAllowedAuthorIDs(db) + if len(allowed) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "list": []gin.H{}, "total": int64(0), "page": page, "pageSize": pageSize}) + return + } + + q := db.Model(&model.SuperArticle{}).Where("user_id IN ?", allowed) + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + + var rows []model.SuperArticle + if err := db.Where("user_id IN ?", allowed). + Order("created_at DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&rows).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + + idSet := map[string]bool{} + var authorIDs []string + for _, r := range rows { + uid := strings.TrimSpace(r.UserID) + if uid == "" || idSet[uid] { + continue + } + idSet[uid] = true + authorIDs = append(authorIDs, uid) + } + authorMap := loadSuperArticleAuthorMap(authorIDs) + + list := make([]gin.H, 0, len(rows)) + for _, r := range rows { + content := strings.TrimSpace(r.Content) + preview := content + if len([]rune(preview)) > 120 { + preview = string([]rune(preview)[:120]) + "..." + } + a := authorMap[strings.TrimSpace(r.UserID)] + list = append(list, gin.H{ + "id": r.ID, + "userId": r.UserID, + "title": r.Title, + "content": r.Content, + "preview": preview, + "images": parseSuperArticleImagesJSON(r.Images), + "authorNickname": strings.TrimSpace(a.Nickname), + "authorAvatar": strings.TrimSpace(a.Avatar), + "createdAt": r.CreatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +func normalizeSuperArticleImageURLs(in []string) ([]string, string) { + if len(in) == 0 { + return []string{}, "" + } + if len(in) > superArticleMaxImages { + return nil, "配图最多 9 张" + } + out := make([]string, 0, len(in)) + for _, u := range in { + u = strings.TrimSpace(u) + if u == "" { + continue + } + if len(u) > superArticleMaxImageURLLen { + return nil, "图片链接过长" + } + low := strings.ToLower(u) + if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { + return nil, "图片链接须以 http(s):// 开头" + } + out = append(out, u) + } + if len(out) > superArticleMaxImages { + return nil, "配图最多 9 张" + } + return out, "" +} + // MiniprogramSuperArticleCreate POST /api/miniprogram/super/articles // 仅超级个体本人可发文章。 func MiniprogramSuperArticleCreate(c *gin.Context) { var req struct { - UserID string `json:"userId"` - Title string `json:"title"` - Content string `json:"content"` + UserID string `json:"userId"` + Title string `json:"title"` + Content string `json:"content"` + Images []string `json:"images"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) @@ -63,6 +215,17 @@ func MiniprogramSuperArticleCreate(c *gin.Context) { return } + imgURLs, imgErr := normalizeSuperArticleImageURLs(req.Images) + if imgErr != "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": imgErr}) + return + } + imgJSONBytes, jerr := json.Marshal(imgURLs) + if jerr != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "配图数据无效"}) + return + } + db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可发文章", "forbidden": true}) @@ -73,6 +236,7 @@ func MiniprogramSuperArticleCreate(c *gin.Context) { UserID: req.UserID, Title: req.Title, Content: req.Content, + Images: string(imgJSONBytes), } if err := db.Create(&row).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) @@ -130,6 +294,7 @@ func MiniprogramSuperArticleList(c *gin.Context) { "title": r.Title, "content": r.Content, "preview": preview, + "images": parseSuperArticleImagesJSON(r.Images), "authorNickname": strings.TrimSpace(author.Nickname), "authorAvatar": strings.TrimSpace(author.Avatar), "createdAt": r.CreatedAt, @@ -172,6 +337,7 @@ func MiniprogramSuperArticleDetail(c *gin.Context) { "userId": row.UserID, "title": row.Title, "content": row.Content, + "images": parseSuperArticleImagesJSON(row.Images), "authorNickname": strings.TrimSpace(author.Nickname), "authorAvatar": strings.TrimSpace(author.Avatar), "createdAt": row.CreatedAt, diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go index 2c0bedc6..abda5a53 100644 --- a/soul-api/internal/handler/upload.go +++ b/soul-api/internal/handler/upload.go @@ -170,7 +170,7 @@ func UploadPost(c *gin.Context) { func uploadPostSaveLocal(c *gin.Context, file *multipart.FileHeader, folder, name, ct string) { uploadDir := config.Get().UploadDir if uploadDir == "" { - uploadDir = "uploads" + uploadDir = "./uploads" } dir := filepath.Join(uploadDir, folder) _ = os.MkdirAll(dir, 0755) @@ -240,7 +240,7 @@ func UploadDelete(c *gin.Context) { } uploadDir := config.Get().UploadDir if uploadDir == "" { - uploadDir = "uploads" + uploadDir = "./uploads" } fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel)) if err := os.Remove(fullPath); err != nil { diff --git a/soul-api/internal/handler/upload_content.go b/soul-api/internal/handler/upload_content.go index edca0143..5809c706 100644 --- a/soul-api/internal/handler/upload_content.go +++ b/soul-api/internal/handler/upload_content.go @@ -19,6 +19,7 @@ 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" @@ -31,6 +32,17 @@ const ( defaultImageQuality = 85 ) +// effectiveUploadDiskDir 与 router.Static("/uploads")、upload.go uploadPostSaveLocal 一致; +// 避免配置了 UPLOAD_DIR(绝对路径)时仍写入进程 cwd 下 ./uploads,导致 GET /uploads/… 读挂载目录 404。 +func effectiveUploadDiskDir() string { + if cfg := config.Get(); cfg != nil { + if d := strings.TrimSpace(cfg.UploadDir); d != "" { + return d + } + } + return "./uploads" +} + var ( allowedImageTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, @@ -140,7 +152,7 @@ func UploadImagePost(c *gin.Context) { } // 回退本地存储 - dir := filepath.Join(uploadDirContent, folder) + dir := filepath.Join(effectiveUploadDiskDir(), folder) _ = os.MkdirAll(dir, 0755) dst := filepath.Join(dir, name) if err := os.WriteFile(dst, finalData, 0644); err != nil { @@ -214,7 +226,7 @@ func UploadVideoPost(c *gin.Context) { } // 回退本地存储 - dir := filepath.Join(uploadDirContent, folder) + dir := filepath.Join(effectiveUploadDiskDir(), folder) _ = os.MkdirAll(dir, 0755) dst := filepath.Join(dir, name) if err := c.SaveUploadedFile(file, dst); err != nil { diff --git a/soul-api/internal/model/super_article.go b/soul-api/internal/model/super_article.go index ff7512ce..7dba3482 100644 --- a/soul-api/internal/model/super_article.go +++ b/soul-api/internal/model/super_article.go @@ -8,6 +8,7 @@ type SuperArticle struct { UserID string `gorm:"column:user_id;size:50;index:idx_super_articles_user_time" json:"userId"` Title string `gorm:"column:title;size:200" json:"title"` Content string `gorm:"column:content;type:text" json:"content"` + Images string `gorm:"column:images;type:text" json:"-"` // JSON 数组 ["https://..."],最多 9 条 CreatedAt time.Time `gorm:"column:created_at;index:idx_super_articles_user_time" json:"createdAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` } diff --git a/soul-api/internal/oss/oss.go b/soul-api/internal/oss/oss.go index 64c6bc20..80c03116 100644 --- a/soul-api/internal/oss/oss.go +++ b/soul-api/internal/oss/oss.go @@ -9,6 +9,7 @@ import ( "log" "net/url" "strings" + "sync" "soul-api/internal/config" "soul-api/internal/database" @@ -180,23 +181,14 @@ func finalizeOssConfig(cfg *Config) bool { return true } -func logOssConfigRejected(cfg *Config) { - if cfg == nil { - log.Printf("oss: oss_config rejected: nil cfg") - return - } - switch { - case cfg.Bucket == "": - log.Printf("oss: oss_config rejected: missing bucket") - case cfg.AccessKeyID == "": - log.Printf("oss: oss_config rejected: missing accessKeyId") - case cfg.AccessKeySecret == "" || ossSecretIsPlaceholder(cfg.AccessKeySecret): - log.Printf("oss: oss_config rejected: missing or placeholder accessKeySecret") - case strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(cfg.Endpoint), "https://"), "http://") == "" && inferEndpointFromRegion(cfg.Region) == "": - log.Printf("oss: oss_config rejected: missing endpoint and region") - default: - log.Printf("oss: oss_config rejected: incomplete") - } +// 配置不完整或 Secret 为占位时走本地上传兜底;进程内只提示一次,避免并发上传刷屏。 +var logLocalFallbackOnce sync.Once +var logOssParseOnce sync.Once + +func noteLocalUploadFallback() { + logLocalFallbackOnce.Do(func() { + log.Print("oss: 未启用(配置不完整或 Secret 为占位),上传已使用本地兜底;可在管理端填写 OSS 或设置环境变量 ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET") + }) } // applyAliyunOSEnvFallback 库中 Secret 为空/占位或 AccessKeyId 为空时,用环境变量 ALIYUN_OSS_ACCESS_KEY_* 补齐(不上传 .env 到公开仓库)。 @@ -228,12 +220,14 @@ func LoadConfig() *Config { } cfg, err := decodeOssConfigBytes(row.ConfigValue, 0) if err != nil { - log.Printf("oss: oss_config parse error: %v", err) + logOssParseOnce.Do(func() { + log.Printf("oss: oss_config parse error: %v", err) + }) return nil } applyAliyunOSEnvFallback(&cfg) if !finalizeOssConfig(&cfg) { - logOssConfigRejected(&cfg) + noteLocalUploadFallback() return nil } return &cfg diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index f813a4d0..8013876a 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -456,6 +456,7 @@ func Setup(cfg *config.Config) *gin.Engine { miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats) miniprogram.GET("/my/super-leads", handler.MiniprogramMySuperLeads) miniprogram.GET("/my/super-lead-tracks", handler.MiniprogramMySuperLeadTracks) + miniprogram.GET("/super/articles/feed", handler.MiniprogramSuperArticleFeed) miniprogram.GET("/super/articles", handler.MiniprogramSuperArticleList) miniprogram.GET("/super/articles/:id", handler.MiniprogramSuperArticleDetail) miniprogram.POST("/super/articles", handler.MiniprogramSuperArticleCreate) diff --git a/soul-api/scripts/create_super_articles.sql b/soul-api/scripts/create_super_articles.sql index 46866e01..e40cbd42 100644 --- a/soul-api/scripts/create_super_articles.sql +++ b/soul-api/scripts/create_super_articles.sql @@ -1,16 +1,21 @@ -- 超级个体文章表(与 soul-api/internal/model/super_article.go、AutoMigrate 一致) --- 线上库缺表时报:Error 1146 (42S02): Table 'soul_miniprogram.super_articles' doesn't exist --- 在目标库执行:mysql soul_miniprogram < create_super_articles.sql --- 或使用 Navicat 选中 soul_miniprogram 后运行本脚本。 +-- 缺表时报:Error 1146 (42S02): Table 'xxx.super_articles' doesn't exist(如 souldev、soul_miniprogram) +-- 在目标库执行:mysql -u... -p souldev < create_super_articles.sql +-- 或使用 Navicat 选中对应库后运行本脚本。 +-- 若 soul-api 已含 ensureSuperArticlesTableRaw:重启 API(SKIP_AUTO_MIGRATE=1 也会自动建表)即可。 CREATE TABLE IF NOT EXISTS `super_articles` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` varchar(50) NOT NULL COMMENT '作者 users.id', `title` varchar(200) NOT NULL DEFAULT '', `content` longtext COMMENT '正文', + `images` longtext COMMENT '配图 URL JSON 数组,最多 9 张', `created_at` datetime(3) DEFAULT NULL, `updated_at` datetime(3) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_super_articles_user_time` (`user_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='超级个体发文(小程序 /s/:id H5)'; + +-- 已有表缺列时执行: +-- ALTER TABLE super_articles ADD COLUMN images LONGTEXT NULL COMMENT '配图 URL JSON 数组,最多 9 张'; diff --git a/配置.txy b/配置.txy new file mode 100644 index 00000000..475d06d4 --- /dev/null +++ b/配置.txy @@ -0,0 +1,151 @@ +server { + listen 80; + listen [::]:80; + server_name souldev.quwanzhi.com; + index index.php index.html index.htm default.php default.htm default.html; + root /www/wwwroot/souldev.quwanzhi.com; + # 以下若有自定义 location ^~ /uploads(走站点 root 静态),会与 soul-api 落盘路径不一致导致 404;应删掉 extension 里同类配置或改为反代。 + include /www/server/panel/vhost/nginx/extension/souldev.quwanzhi.com/*.conf; + #CERT-APPLY-CHECK--START + + # 用于SSL证书申请时的文件验证相关配置 -- 请勿删除 + include /www/server/panel/vhost/nginx/well-known/souldev.quwanzhi.com.conf; + #CERT-APPLY-CHECK--END + + #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则 + + #error_page 404/404.html; + + #SSL-END + + #REDIRECT START + + #REDIRECT END + + #ERROR-PAGE-START 错误页配置,可以注释、删除或修改 + + #error_page 404 /404.html; + + #error_page 502 /502.html; + + #ERROR-PAGE-END + + #PHP-INFO-START PHP引用配置,可以注释或修改 + include enable-php-00.conf; + #PHP-INFO-END + + #IP-RESTRICT-START 限制访问ip的配置,IP黑白名单 + + #IP-RESTRICT-END + + #BASICAUTH START + + #BASICAUTH END + + #SUB_FILTER START + + #SUB_FILTER END + + #GZIP START + + #GZIP END + + #GLOBAL-CACHE START + + #GLOBAL-CACHE END + + # soul-api 上传目录直出(与服务器 .env 中 UPLOAD_DIR 保持一致;勿在 extension 里再写 location /uploads 以免冲突) + location ^~ /uploads/ { + alias /www/wwwroot/self/soul-api/uploads/; + access_log off; + expires 7d; + add_header Cache-Control "public"; + } + + #WEBSOCKET-SUPPORT START + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + #WEBSOCKET-SUPPORT END + + #PROXY-CONF-START + location ^~ / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host 127.0.0.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + # 支持websocket链接 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + #PROXY-CONF-END + + #SERVER-BLOCK START + + #SERVER-BLOCK END + + #禁止访问的文件或目录 + location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) { + return 404; + } + #一键申请SSL证书验证目录相关设置 + location /.well-known { + allow all; + } + #禁止在证书验证目录放入敏感文件 + if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { + return 403; + } + #LOG START + access_log /www/wwwlogs/souldev.quwanzhi.com.log; + error_log /www/wwwlogs/souldev.quwanzhi.com.error.log; + #LOG END + +} +# SSL server — 由脚本追加(面板有证书但未写入 443 时) +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name souldev.quwanzhi.com; + ssl_certificate /www/server/panel/vhost/ssl/souldev.quwanzhi.com/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/ssl/souldev.quwanzhi.com/privkey.pem; + ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_tickets on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + location ^~ /uploads/ { + alias /www/wwwroot/self/soul-api/uploads/; + access_log off; + expires 7d; + add_header Cache-Control "public"; + } + location ^~ / { + proxy_pass http://127.0.0.1:9092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + access_log /www/wwwlogs/souldev.quwanzhi.com.log; + error_log /www/wwwlogs/souldev.quwanzhi.com.error.log; +} \ No newline at end of file