From e79152c80b4c66fa7932246ae44889cc4939193a Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:58:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=88=87=E6=8D=A2=20API=20=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E4=B8=BA=E6=9C=AC=E5=9C=B0=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E3=80=82=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=A4=B4=E5=83=8F=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=94=A8=E6=88=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B1=95=E7=A4=BA=E4=B8=80=E8=87=B4=E6=80=A7=E3=80=82?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E4=B8=AA=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=8F=90=E7=A4=BA=EF=BC=8C=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=98=A0=E5=B0=84=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=96=B0=E6=A0=B7=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/app.js | 4 +- miniprogram/components/icon/icon.js | 120 +++++++++++-- miniprogram/pages/index/index.wxml | 25 ++- .../pages/member-detail/member-detail.wxml | 4 +- miniprogram/pages/my/my.js | 7 +- miniprogram/pages/my/my.wxml | 9 +- miniprogram/pages/referral/referral.wxml | 2 +- miniprogram/pages/vip/vip.wxml | 13 +- miniprogram/project.private.config.json | 11 +- .../src/pages/chapters/ChaptersPage.tsx | 12 +- soul-admin/src/pages/content/ChapterTree.tsx | 82 ++++++++- soul-admin/src/pages/content/ContentPage.tsx | 164 +++++++++++++++--- .../pages/distribution/DistributionPage.tsx | 83 +++++++-- soul-admin/src/pages/users/UsersPage.tsx | 3 +- soul-api/.dockerignore | 6 +- soul-api/devloy.py | 112 +++++++++--- soul-api/internal/cache/cache.go | 50 ++++-- soul-api/internal/cache/memory.go | 52 ++++++ soul-api/internal/handler/db.go | 23 ++- soul-api/internal/handler/db_book.go | 32 ++++ soul-api/internal/redis/redis.go | 2 + 21 files changed, 658 insertions(+), 158 deletions(-) create mode 100644 soul-api/internal/cache/memory.go diff --git a/miniprogram/app.js b/miniprogram/app.js index 69a2f766..2a209aad 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -13,8 +13,8 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' App({ globalData: { // API 基础地址:开发时修改下面一行切换环境 - baseUrl: "https://soulapi.quwanzhi.com", - // baseUrl: 'http://localhost:8080', // 开发 + // baseUrl: "https://soulapi.quwanzhi.com", + baseUrl: 'http://localhost:8080', // 开发 // baseUrl: 'https://souldev.quwanzhi.com', // 测试 // 小程序配置 - 真实AppID appId: DEFAULT_APP_ID, diff --git a/miniprogram/components/icon/icon.js b/miniprogram/components/icon/icon.js index bfbcfd03..095a9068 100644 --- a/miniprogram/components/icon/icon.js +++ b/miniprogram/components/icon/icon.js @@ -42,38 +42,127 @@ Component({ }, methods: { - // iconfont 映射:将业务 name(lucide 风格)映射到 iconfont 的 unicode(形如 "\ue6aa") + // iconfont 映射:与 static/iconfont.wxss 一一对应(icon-xxx -> xxx) // 小程序不支持通过 :before { content } 渲染,因此必须直接输出 unicode 字符 getFontGlyph(name) { const map = { - // 基础高频(来自 static/iconfont.css 的 content 值) - 'wallet': '\ue6c8', + // === 来自 iconfont.wxss 完整映射 === + 'qianbao': '\ue6c8', 'gift': '\ue6c9', + 'zap1': '\ue75c', 'user': '\ue6b9', + 'upload': '\ue6ba', + 'work': '\ue6bb', + 'training': '\ue6bc', + 'warning': '\ue6bd', + 'zoom-in': '\ue6be', + 'zoom-out': '\ue6bf', + 'arrow-left-bold': '\ue6c1', + 'arrow-up-bold': '\ue6c2', + 'close-bold': '\ue6c3', + 'arrow-down-bold': '\ue6c4', + 'minus-bold': '\ue6c5', + 'arrow-right-bold': '\ue6c6', + 'select-bold': '\ue6c7', + 'money-wallet': '\ue833', + 'book-open': '\ue993', + 'biaoshilei_yonghuzu': '\ue61b', + 'add': '\ue664', + 'add-circle': '\ue665', + 'adjust': '\ue666', + 'arrow-up-circle': '\ue667', + 'arrow-right-circle': '\ue668', + 'arrow-down': '\ue669', + 'ashbin': '\ue66a', + 'arrow-right': '\ue66b', + 'browse': '\ue66c', + 'bottom': '\ue66d', + 'back': '\ue66e', + 'bad': '\ue66f', + 'arrow-left-circle': '\ue670', + 'camera': '\ue671', + 'chart-bar': '\ue672', + 'attachment': '\ue673', + 'code': '\ue674', + 'close': '\ue675', + 'check-item': '\ue676', + 'calendar': '\ue677', + 'comment': '\ue678', + 'complete': '\ue679', + 'direction-down': '\ue67a', + 'direction-down-circle': '\ue67b', + 'direction-right': '\ue67c', + 'direction-up': '\ue67d', + 'discount': '\ue67e', + 'electronics': '\ue681', + 'elipsis': '\ue682', + 'export': '\ue683', + 'explain': '\ue684', + 'edit': '\ue685', + 'eye-close': '\ue686', + 'email': '\ue687', + 'error': '\ue688', + 'favorite': '\ue689', + 'file-common': '\ue68a', + 'file-delete': '\ue68b', + 'file-add': '\ue68c', + 'film': '\ue68d', + 'fabulous': '\ue68e', + 'file': '\ue68f', + 'folder-close': '\ue690', + 'filter': '\ue691', + 'good': '\ue692', + 'hide': '\ue693', + 'home': '\ue694', + 'file-open': '\ue695', + 'forward': '\ue696', + 'import': '\ue697', + 'layers': '\ue698', + 'lock': '\ue699', + 'map': '\ue69a', + 'menu': '\ue69b', + 'help': '\ue69c', + 'minus-circle': '\ue69d', + 'notification': '\ue69e', + 'more': '\ue69f', + 'mobile-phone': '\ue6a0', + 'minus': '\ue6a1', + 'navigation': '\ue6a2', + 'prompt': '\ue6a3', + 'refresh': '\ue6a4', + 'run-up': '\ue6a5', + 'picture': '\ue6a6', + 'run-in': '\ue6a7', + 'pin': '\ue6a8', + 'save': '\ue6a9', 'search': '\ue6aa', 'share': '\ue6ab', - 'home': '\ue694', - 'lock': '\ue699', - 'camera': '\ue671', - 'warning': '\ue6bd', + 'scanning': '\ue6ac', + 'security': '\ue6ad', + 'sign-out': '\ue6ae', + 'select': '\ue6af', + 'stop': '\ue6b0', + 'success': '\ue6b1', + 'switch': '\ue6b2', + 'setting': '\ue6b3', + 'survey': '\ue6b4', + 'time': '\ue6b5', + 'telephone': '\ue6b6', + 'top': '\ue6b7', + 'unlock': '\ue6b8', - // 箭头/展开 + // === 业务别名(兼容 lucide 等命名)=== + 'wallet': '\ue6c8', 'chevron-left': '\ue6c1', 'chevron-right': '\ue6c6', 'chevron-down': '\ue6c4', 'chevron-up': '\ue6c2', - 'arrow-up-right': '\ue6c2', - - // 交互/状态 'x': '\ue6c3', 'check': '\ue6c7', - 'plus': '\ue664', 'trash-2': '\ue66a', 'pencil': '\ue685', 'zap': '\ue75c', 'info': '\ue69c', - - // 语义近似映射(iconfont 不一定有同名) 'map-pin': '\ue6a8', 'message-circle': '\ue678', 'smartphone': '\ue6a0', @@ -81,9 +170,6 @@ Component({ 'shield': '\ue6ad', 'star': '\ue689', 'heart': '\ue68e', - - // 其他:若 iconfont 里不存在,则继续走 SVG 兜底 - 'book-open': '\ue993', 'bar-chart': '\ue672', 'clock': '\ue6b5', } diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index b6040be0..2e0aec8b 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -38,18 +38,19 @@ @@ -78,7 +79,7 @@ > - {{item.name[0] || '会'}} + {{(item.name && item.name[0]) || '会'}} {{item.name}} @@ -87,7 +88,7 @@ 成为会员,展示你的项目 - 加入创业派对 + 加入创业派对 → @@ -112,7 +113,7 @@ {{item.id}} - {{item.tag || '精选'}} + {{item.tag}} {{item.title}} @@ -142,13 +143,7 @@ - - NEW - {{item.title}} - - - ¥{{item.price}} - + {{item.title}} diff --git a/miniprogram/pages/member-detail/member-detail.wxml b/miniprogram/pages/member-detail/member-detail.wxml index 88dc8b73..46ae3704 100644 --- a/miniprogram/pages/member-detail/member-detail.wxml +++ b/miniprogram/pages/member-detail/member-detail.wxml @@ -18,7 +18,7 @@ - {{member.name[0] || '创'}} + {{(member.name && member.name[0]) || '创'}} VIP @@ -132,7 +132,7 @@ 成为超级个体 - + diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index b63927c4..c4480b69 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -429,9 +429,12 @@ Page({ return } const d = res.data + // 我的收益 = 累计佣金;我的余额 = 可提现金额(兼容 snake_case) + const totalCommission = d.totalCommission ?? d.total_commission ?? 0 + const availableEarnings = d.availableEarnings ?? d.available_earnings ?? 0 this.setData({ - earnings: formatMoney(d.totalCommission), - pendingEarnings: formatMoney(d.availableEarnings), + earnings: formatMoney(totalCommission), + pendingEarnings: formatMoney(availableEarnings), referralCount: d.referralCount ?? this.data.referralCount, earningsLoading: false, earningsRefreshing: false diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index 53935d6d..0a1d8863 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -88,10 +88,7 @@ 将依次调起微信收款页完成领取 - - 查看提现记录 - - + 查看提现记录 › @@ -144,7 +141,7 @@ 暂无阅读记录 - 去阅读 + 去阅读 → @@ -200,7 +197,7 @@ 请完善联系方式 - 手机号必填,微信号建议填写,以便使用提现和找伙伴功能 + 需完善手机号或微信号才能使用提现和找伙伴功能 手机号 diff --git a/miniprogram/pages/referral/referral.wxml b/miniprogram/pages/referral/referral.wxml index 9c7abd4f..d1bb3d0d 100644 --- a/miniprogram/pages/referral/referral.wxml +++ b/miniprogram/pages/referral/referral.wxml @@ -134,7 +134,7 @@ - {{item.nickname[0] || '用'}} + {{(item.nickname && item.nickname[0]) || '用'}} - 加入卡若的 - 创业派对 会员 + 加入卡若 + 创业派对 VIP会员 有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天) - 一次加入 尊享终身陪伴与成长 - 内容权益 + 会员权利 @@ -33,7 +32,7 @@ - 社交权益 + 派对权利 @@ -47,7 +46,7 @@ - {{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}} + {{purchasing ? "处理中..." : "立即支付" + price + "元 加入创业派对"}} diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json index 805549b4..a95b8ef7 100644 --- a/miniprogram/project.private.config.json +++ b/miniprogram/project.private.config.json @@ -23,12 +23,19 @@ "condition": { "miniprogram": { "list": [ + { + "name": "pages/member-detail/member-detail", + "pathName": "pages/member-detail/member-detail", + "query": "id=ogpTW5cVMxd5afBBtXdvmeMO8aho", + "scene": null, + "launchMode": "default" + }, { "name": "pages/my/my", "pathName": "pages/my/my", "query": "", - "scene": null, - "launchMode": "default" + "launchMode": "default", + "scene": null }, { "name": "个人资料", diff --git a/soul-admin/src/pages/chapters/ChaptersPage.tsx b/soul-admin/src/pages/chapters/ChaptersPage.tsx index 93743ede..b16b77fd 100644 --- a/soul-admin/src/pages/chapters/ChaptersPage.tsx +++ b/soul-admin/src/pages/chapters/ChaptersPage.tsx @@ -1,4 +1,4 @@ -import toast from '@/utils/toast' +import toast from '@/utils/toast' import { useState, useEffect } from 'react' import { get, post } from '@/api/client' @@ -33,6 +33,13 @@ interface Stats { totalParts: number } +/** 篇内章区间(按章条数,非节数) */ +function chapterRangeLabel(chapterCount: number): string { + if (chapterCount <= 0) return '暂无章节' + if (chapterCount === 1) return '第1章' + return `第1章 ~ 第${chapterCount}章` +} + export function ChaptersPage() { const [structure, setStructure] = useState([]) const [stats, setStats] = useState(null) @@ -204,7 +211,8 @@ export function ChaptersPage() { {part.title} - ({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节) + ({chapterRangeLabel(part.chapters.length)} ·{' '} + {part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节) {expandedParts.includes(part.id) ? '▲' : '▼'} diff --git a/soul-admin/src/pages/content/ChapterTree.tsx b/soul-admin/src/pages/content/ChapterTree.tsx index 237ee350..4d2d4b45 100644 --- a/soul-admin/src/pages/content/ChapterTree.tsx +++ b/soul-admin/src/pages/content/ChapterTree.tsx @@ -2,7 +2,7 @@ * 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序 * 整行可拖拽;节和章可跨篇 */ -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -12,6 +12,8 @@ export interface SectionItem { id: string title: string price: number + /** 数据库自增序号,仅作兜底展示 */ + mid?: number isFree?: boolean isNew?: boolean clickCount?: number @@ -34,6 +36,33 @@ export interface PartItem { type DragType = 'part' | 'chapter' | 'section' +function isPrefacePart(p: PartItem) { + return p.title === '序言' || p.title.includes('序言') +} + +/** 篇内按章数量展示:第1章 ~ 第n章(仅章数,与节数无关) */ +function chapterRangeSubtitle(chapterCount: number): string { + if (chapterCount <= 0) return '暂无章节' + if (chapterCount === 1) return '第1章' + return `第1章 ~ 第${chapterCount}章` +} + +/** 全书节序号:跳过序言篇后从 1 连续编号(与列表拖拽顺序一致) */ +function buildBodySectionOrderMap(parts: PartItem[]): Map { + const m = new Map() + let n = 0 + for (const part of parts) { + if (isPrefacePart(part)) continue + for (const ch of part.chapters) { + for (const s of ch.sections) { + n += 1 + m.set(s.id, n) + } + } + } + return m +} + function parseDragData(data: string): { type: DragType; id: string } | null { if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) } if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) } @@ -84,6 +113,8 @@ export function ChapterTree({ const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null) const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null) + const bodySectionOrderMap = useMemo(() => buildBodySectionOrderMap(parts), [parts]) + const isDragging = (type: DragType, id: string) => draggingItem?.type === type && draggingItem?.id === id const isDragOver = (type: DragType, id: string) => dragOverTarget?.type === type && dragOverTarget?.id === id @@ -267,6 +298,33 @@ export function ChapterTree({ const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1) + /** 篇角标:去掉序言后第一篇为「一」 */ + const bodyPartOrdinal = (partIndex: number) => + parts.slice(0, partIndex).filter((p) => !isPrefacePart(p)).length + + const sectionTitleLine = (section: SectionItem) => { + const ord = bodySectionOrderMap.get(section.id) + if (ord != null) { + return ( + <> + + {ord}. + + {section.title} + + ) + } + return ( + + {section.mid != null && section.mid > 0 ? `${section.mid}. ` : ''} + {section.title} + + ) + } + return (
{parts.map((part, partIndex) => { @@ -381,7 +439,7 @@ export function ChapterTree({

{part.title}

-

共 {sectionCount} 节

+

{chapterRangeSubtitle(chapterCount)}

)} - {chapterCount}章 + + {chapterCount} 章 · {sectionCount} 节 + {isExpanded ? ( ) : ( @@ -470,7 +530,9 @@ export function ChapterTree({ /> )} - {section.id} {section.title} + + {sectionTitleLine(section)} + {pinnedSectionIds.includes(section.id) && }
@@ -739,11 +801,11 @@ export function ChapterTree({
- {partLabel(partIndex)} + {partLabel(bodyPartOrdinal(partIndex))}

{part.title}

-

共 {sectionCount} 节

+

{chapterRangeSubtitle(chapterCount)}

)} - {chapterCount}章 + + {chapterCount} 章 · {sectionCount} 节 + {isExpanded ? ( ) : ( @@ -872,8 +936,8 @@ export function ChapterTree({
- - {section.id} {section.title} + + {sectionTitleLine(section)} {pinnedSectionIds.includes(section.id) && }
diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 7469d706..f8fd215e 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -210,7 +210,17 @@ export function ContentPage() { const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null) const [isSavingPartTitle, setIsSavingPartTitle] = useState(false) const [showNewPartModal, setShowNewPartModal] = useState(false) - const [editingChapter, setEditingChapter] = useState<{ part: Part; chapter: Chapter; title: string } | null>(null) + const [editingChapter, setEditingChapter] = useState<{ + part: Part + chapter: Chapter + title: string + price: number + isFree: boolean + priceMixed: boolean + initialTitle: string + initialPrice: number + initialIsFree: boolean + } | null>(null) const [isSavingChapterTitle, setIsSavingChapterTitle] = useState(false) const [selectedSectionIds, setSelectedSectionIds] = useState([]) const [showBatchMoveModal, setShowBatchMoveModal] = useState(false) @@ -914,40 +924,98 @@ export function ContentPage() { } const handleEditChapter = (part: Part, chapter: Chapter) => { - setEditingChapter({ part, chapter, title: chapter.title }) + const secs = chapter.sections + let price = 1 + let isFree = false + let priceMixed = false + if (secs.length > 0) { + const p0 = typeof secs[0].price === 'number' ? secs[0].price : Number(secs[0].price) || 1 + const f0 = !!(secs[0].isFree || p0 === 0) + priceMixed = secs.some((s) => { + const p = typeof s.price === 'number' ? s.price : Number(s.price) || 1 + const f = !!(s.isFree || p === 0) + return p !== p0 || f !== f0 + }) + price = f0 ? 0 : p0 + isFree = f0 + } + setEditingChapter({ + part, + chapter, + title: chapter.title, + price, + isFree, + priceMixed, + initialTitle: chapter.title, + initialPrice: price, + initialIsFree: isFree, + }) } const handleSaveChapterTitle = async () => { if (!editingChapter?.title?.trim()) return + const ec = editingChapter + const newTitle = ec.title.trim() + const titleChanged = newTitle !== ec.initialTitle + const priceChanged = + ec.isFree !== ec.initialIsFree || + (!ec.isFree && Number(ec.price) !== Number(ec.initialPrice)) + + if (!titleChanged && !priceChanged) { + toast.info('未修改任何内容') + setEditingChapter(null) + return + } + + if (ec.priceMixed && priceChanged) { + const n = ec.chapter.sections.length + const tip = ec.isFree ? '全部设为免费' : `全部设为 ¥${ec.price}` + if (!confirm(`本章 ${n} 节当前定价不一致,保存后将${tip},确定?`)) return + } + setIsSavingChapterTitle(true) try { - const items = sectionsList.map((s) => ({ - id: s.id, - partId: s.partId || editingChapter.part.id, - partTitle: s.partId === editingChapter.part.id ? editingChapter.part.title : (s.partTitle || ''), - chapterId: s.chapterId || editingChapter.chapter.id, - chapterTitle: - s.partId === editingChapter.part.id && s.chapterId === editingChapter.chapter.id - ? editingChapter.title.trim() - : (s.chapterTitle || ''), - })) - const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items }) - if (res && (res as { success?: boolean }).success !== false) { - const newTitle = editingChapter.title.trim() - const partId = editingChapter.part.id - const chapterId = editingChapter.chapter.id + if (titleChanged) { + const items = sectionsList.map((s) => ({ + id: s.id, + partId: s.partId || ec.part.id, + partTitle: s.partId === ec.part.id ? ec.part.title : (s.partTitle || ''), + chapterId: s.chapterId || ec.chapter.id, + chapterTitle: + s.partId === ec.part.id && s.chapterId === ec.chapter.id ? newTitle : (s.chapterTitle || ''), + })) + const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items }) + if (res && (res as { success?: boolean }).success === false) { + toast.error('保存章节名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误')) + return + } + const partId = ec.part.id + const chapterId = ec.chapter.id setSectionsList((prev) => prev.map((s) => - s.partId === partId && s.chapterId === chapterId - ? { ...s, chapterTitle: newTitle } - : s + s.partId === partId && s.chapterId === chapterId ? { ...s, chapterTitle: newTitle } : s ) ) - setEditingChapter(null) - loadList() - } else { - toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误')) } + + if (priceChanged) { + const res2 = await put<{ success?: boolean; error?: string; affected?: number }>('/api/db/book', { + action: 'update-chapter-pricing', + partId: ec.part.id, + chapterId: ec.chapter.id, + price: ec.isFree ? 0 : Number(ec.price) || 0, + isFree: ec.isFree, + }) + if (res2 && (res2 as { success?: boolean }).success === false) { + toast.error('保存定价失败: ' + (res2 && typeof res2 === 'object' && 'error' in res2 ? (res2 as { error?: string }).error : '未知错误')) + if (titleChanged) loadList() + return + } + } + + setEditingChapter(null) + loadList() + toast.success('已保存') } catch (e) { console.error(e) toast.error('保存失败') @@ -1471,14 +1539,17 @@ export function ContentPage() { - {/* 编辑章节名称弹窗 */} + {/* 编辑章节:名称 + 本章统一定价(对齐新版管理端能力) */} !open && setEditingChapter(null)}> - 编辑章节名称 + 章节设置 +

+ 修改本章显示名称,或为本章下全部节设置统一金额(仍可在单节编辑里单独改某一节)。 +

{editingChapter && (
@@ -1491,6 +1562,47 @@ export function ContentPage() { placeholder="输入章节名称" />
+
+ + {editingChapter.priceMixed && ( +

当前各节定价不一致,保存后将按下方设置全部统一。

+ )} +
+
+ 价格 (元) + + setEditingChapter({ + ...editingChapter, + price: Number(e.target.value), + isFree: Number(e.target.value) === 0, + }) + } + disabled={editingChapter.isFree} + min={0} + step={0.01} + /> +
+ +
+
)} diff --git a/soul-admin/src/pages/distribution/DistributionPage.tsx b/soul-admin/src/pages/distribution/DistributionPage.tsx index 8d86ea45..c16ea6ef 100644 --- a/soul-admin/src/pages/distribution/DistributionPage.tsx +++ b/soul-admin/src/pages/distribution/DistributionPage.tsx @@ -648,11 +648,26 @@ export function DistributionPage() { {(overview.todayClicksByPage?.length ?? 0) > 0 && ( - - - 每篇文章今日点击(按来源页/文章统计) - -

实际用户与实际文章的点击均计入;今日总点击与上表一致

+
+
+ + + 每篇文章今日点击(按来源页/文章统计) + +

实际用户与实际文章的点击均计入;今日总点击与上表一致

+
+ +
@@ -832,8 +847,8 @@ export function DistributionPage() { {activeTab === 'orders' && (
-
-
+
+
setStatusFilter(e.target.value)} - className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white" + className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0" > @@ -853,6 +868,16 @@ export function DistributionPage() { +
@@ -1029,8 +1054,8 @@ export function DistributionPage() { {activeTab === 'bindings' && (
-
-
+
+
setStatusFilter(e.target.value)} - className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white" + className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0" > +
@@ -1132,8 +1167,8 @@ export function DistributionPage() { {activeTab === 'withdrawals' && (
-
-
+
+
setStatusFilter(e.target.value)} - className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white" + className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0" > +
@@ -1289,8 +1334,14 @@ export function DistributionPage() { -
diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 2e2229d9..9df9776a 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -764,8 +764,7 @@ export function UsersPage() { {/* ===== 获客列表(存客宝) ===== */} -
-

存客宝获客计划添加的客户信息,来自链接卡若留资

+
diff --git a/soul-api/.dockerignore b/soul-api/.dockerignore index f69dfd41..c2a12be4 100644 --- a/soul-api/.dockerignore +++ b/soul-api/.dockerignore @@ -2,16 +2,16 @@ .git .gitignore -# 本地开发(部署时 .env.development / .env.production 需打包进镜像) +# 本地开发(部署时环境文件需进构建上下文,由 Dockerfile COPY ${ENV_FILE} → /app/.env) .env .env.*.local *.local !.env.development !.env.production +!.env # 构建产物 -soul-api -soul-api-* +# 注意:不可忽略 soul-api —— Dockerfile.local 需 COPY 本地交叉编译的二进制;多阶段 Dockerfile 内 go build 会覆盖 *.exe __pycache__ *.pyc diff --git a/soul-api/devloy.py b/soul-api/devloy.py index fead3825..c2aa4c3e 100644 --- a/soul-api/devloy.py +++ b/soul-api/devloy.py @@ -3,10 +3,18 @@ """ soul-api 一键部署到宝塔【测试环境】 +打包原则:优先使用本地已有资源,不边打包边下载(省时)。 +- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像) +- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存 + 两种模式均部署到测试环境 /www/wwwroot/self/soul-dev: - binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development -- docker:Docker 镜像 + 蓝绿无缝切换,用 .env.development 打包进镜像 +- docker(默认):本地 go build → Dockerfile.local 打镜像 → 蓝绿无缝切换 + - 镜像内包含:二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env + - 环境文件:--env-file 或 DOCKER_ENV_FILE,否则自动 .env.development > .env.production > .env(.dockerignore 已放行) + - 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网 + - 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25) 环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等 """ @@ -117,7 +125,7 @@ def run_build(root): # ==================== 打包 ==================== -DEPLOY_PORT = 8081 +DEPLOY_PORT = 9001 def set_env_port(env_path, port=DEPLOY_PORT): @@ -163,22 +171,32 @@ def set_env_mini_program_state(env_path, state): f.writelines(new_lines) +def resolve_binary_pack_env_src(root): + """binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。""" + for name in (".env.development", ".env.production", ".env"): + p = os.path.join(root, name) + if os.path.isfile(p): + return p, name + return None, None + + def pack_deploy(root, binary_path, include_env=True): """打包二进制和 .env 为 tar.gz""" print("[2/4] 打包部署文件 ...") staging = tempfile.mkdtemp(prefix="soul_api_deploy_") try: shutil.copy2(binary_path, os.path.join(staging, "soul-api")) - env_src = os.path.join(root, ".env.development") staging_env = os.path.join(staging, ".env") - if include_env and os.path.isfile(env_src): - shutil.copy2(env_src, staging_env) - print(" [已包含] .env.development -> .env") - else: - env_example = os.path.join(root, ".env.example") - if os.path.isfile(env_example): - shutil.copy2(env_example, staging_env) - print(" [已包含] .env.example -> .env (请服务器上检查配置)") + if include_env: + env_src, env_label = resolve_binary_pack_env_src(root) + if env_src: + shutil.copy2(env_src, staging_env) + print(" [已包含] %s -> .env" % env_label) + else: + env_example = os.path.join(root, ".env.example") + if os.path.isfile(env_example): + shutil.copy2(env_example, staging_env) + print(" [已包含] .env.example -> .env (请服务器上检查配置)") if os.path.isfile(staging_env): set_env_port(staging_env, DEPLOY_PORT) set_env_mini_program_state(staging_env, "developer") @@ -392,6 +410,37 @@ def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port): # ==================== Docker 部署(蓝绿无缝切换) ==================== +def resolve_docker_env_file(root, explicit=None): + """ + 选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。 + Dockerfile: COPY ${ENV_FILE} /app/.env;certs/ 由 COPY certs/ 一并打入。 + 优先级:explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env(与测试环境默认一致) + """ + if explicit: + name = os.path.basename(explicit.replace("\\", "/")) + path = os.path.join(root, name) + if os.path.isfile(path): + print(" [镜像配置] 打入镜像的环境文件: %s(--env-file)" % name) + return name + print(" [失败] --env-file 不存在: %s" % path) + return None + override = (os.environ.get("DOCKER_ENV_FILE") or "").strip() + if override: + name = os.path.basename(override.replace("\\", "/")) + path = os.path.join(root, name) + if os.path.isfile(path): + print(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_ENV_FILE)" % name) + return name + print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path) + return None + for name in (".env.development", ".env.production", ".env"): + if os.path.isfile(os.path.join(root, name)): + print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name) + return name + print(" [失败] 未找到 .env.development / .env.production / .env,无法 COPY 进镜像") + return None + + def run_docker_build(root, env_file=".env.development"): """本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)""" print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)") @@ -415,14 +464,14 @@ def run_docker_build(root, env_file=".env.development"): def run_docker_build_local(root, env_file=".env.development"): - """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像)""" + """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像,--pull=false 不拉 base 镜像)""" print("[1/5] 使用本地 Go 交叉编译 ...") binary_path = run_build(root) if not binary_path: return None - print("[2/5] 使用 Dockerfile.local 构建镜像 ...(进度见下方 Docker 输出)") + print("[2/5] 使用 Dockerfile.local 构建镜像 ...(--pull=false 仅用本地缓存)") try: - cmd = ["docker", "build", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest", + cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest", "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."] r = subprocess.run(cmd, cwd=root, shell=False, timeout=120) if r.returncode != 0: @@ -444,7 +493,7 @@ def run_docker_build_local(root, env_file=".env.development"): def pack_docker_image(root): """将 soul-api 与 redis 镜像一并导出为 tar.gz,服务器无需拉取""" import gzip - print("[2/5] 导出镜像为 tar.gz(soul-api + redis)...") + print("[3/5] 导出镜像为 tar.gz(soul-api + redis)...") out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz") try: r = subprocess.run( @@ -479,7 +528,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF) script_dir = os.path.dirname(os.path.abspath(__file__)) - print("[3/5] SSH 上传镜像与配置 ...") + print("[4/5] SSH 上传镜像与配置 ...") if not cfg.get("password") and not cfg.get("ssh_key"): print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") return False @@ -506,9 +555,11 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho if os.path.isfile(deploy_local): sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh") print(" [已上传] docker-deploy-remote.sh") + # 注意:docker-compose.bluegreen.yml 未配置 env_file,容器实际以镜像内 /app/.env 为准; + # 此处上传仅供服务器目录备份或手工改 compose 后使用。 if include_env and os.path.isfile(env_local): sftp.put(env_local, deploy_path.rstrip("/") + "/.env") - print(" [已上传] .env.production -> .env(覆盖镜像内配置)") + print(" [已上传] .env.production -> 服务器 %s/.env(可选;默认不挂载进容器)" % deploy_path.rstrip("/")) # btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx current_active = "blue" @@ -523,7 +574,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho sftp.close() - print("[4/5] 执行蓝绿部署 ...") + print("[5/5] 执行蓝绿部署 ...") env_exports = "" if nginx_conf: env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''") @@ -546,12 +597,12 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho # btapi 模式:通过宝塔 API 更新 Nginx 配置并重载(new_port 已在上方计算) if deploy_method == "btapi" and nginx_conf: try: - print("[5/5] 宝塔 API 更新 Nginx ...") + print(" [宝塔 API] 更新 Nginx ...") deploy_nginx_via_bt_api(cfg, nginx_conf, new_port) except Exception as e: print(" [警告] 宝塔 Nginx API 失败:", str(e)) - print("[5/5] 部署完成,蓝绿无缝切换") + print(" 部署完成,蓝绿无缝切换") return True except Exception as e: print(" [失败] SSH 错误:", str(e)) @@ -568,14 +619,17 @@ def main(): parser.add_argument("--mode", choices=("binary", "docker"), default="docker", help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制") parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建") - parser.add_argument("--no-env", action="store_true", help="不打包 .env") + parser.add_argument("--no-env", action="store_true", + help="binary: 不打进 tar;docker: 不上传服务器目录 .env.production(镜像内配置不变)") parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启") parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto", help="[binary] 重启方式: auto/btapi/ssh") - parser.add_argument("--local-go", action="store_true", - help="[docker] 使用本地 Go 交叉编译后打镜像,不拉取 golang 镜像") + parser.add_argument("--docker-in-go", action="store_true", + help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)") parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh", help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)") + parser.add_argument("--env-file", default=None, metavar="NAME", + help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env)") args = parser.parse_args() script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -592,11 +646,19 @@ def main(): print("=" * 60) if not args.no_build: - ok = run_docker_build_local(root) if args.local_go else run_docker_build(root) + env_for_image = resolve_docker_env_file(root, explicit=args.env_file) + if env_for_image is None: + return 1 + # 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译 + ok = ( + run_docker_build(root, env_file=env_for_image) + if args.docker_in_go + else run_docker_build_local(root, env_file=env_for_image) + ) if not ok: return 1 else: - print("[1/5] 跳过构建,使用现有 soul-api:latest") + print("[1/5] 跳过构建,使用现有 soul-api:latest(无需本地环境文件)") image_tar = pack_docker_image(root) if not image_tar: diff --git a/soul-api/internal/cache/cache.go b/soul-api/internal/cache/cache.go index e2dd4102..8c4dff69 100644 --- a/soul-api/internal/cache/cache.go +++ b/soul-api/internal/cache/cache.go @@ -48,10 +48,15 @@ const KeyConfigAuditMode = "soul:config:audit-mode" const KeyConfigCore = "soul:config:core" const KeyConfigReadExtras = "soul:config:read-extras" -// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB) +// Get 从 Redis 读取,未配置或失败时尝试内存备用;均失败返回 false(调用方回退 DB) func Get(ctx context.Context, key string, dest interface{}) bool { client := redis.Client() if client == nil { + // Redis 不可用,使用内存备用 + if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 { + _ = json.Unmarshal(data, dest) + return true + } return false } if ctx == nil { @@ -61,6 +66,11 @@ func Get(ctx context.Context, key string, dest interface{}) bool { defer cancel() val, err := client.Get(ctx, key).Bytes() if err != nil { + // Redis 超时/失败时尝试内存备用 + if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 { + _ = json.Unmarshal(data, dest) + return true + } return false } if dest != nil && len(val) > 0 { @@ -69,10 +79,16 @@ func Get(ctx context.Context, key string, dest interface{}) bool { return true } -// Set 写入 Redis,失败仅打日志不阻塞 +// Set 写入 Redis,Redis 不可用时写入内存备用;失败仅打日志不阻塞 func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) { + data, err := json.Marshal(val) + if err != nil { + log.Printf("cache.Set marshal %s: %v", key, err) + return + } client := redis.Client() if client == nil { + memorySet(key, data, ttl) return } if ctx == nil { @@ -80,22 +96,20 @@ func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) { } ctx, cancel := context.WithTimeout(ctx, defaultTimeout) defer cancel() - data, err := json.Marshal(val) - if err != nil { - log.Printf("cache.Set marshal %s: %v", key, err) - return - } if err := client.Set(ctx, key, data, ttl).Err(); err != nil { - log.Printf("cache.Set %s: %v (非致命)", key, err) + log.Printf("cache.Set %s: %v (非致命),已写入内存备用", key, err) + memorySet(key, data, ttl) } } -// Del 删除 key,失败仅打日志 +// Del 删除 key,Redis 不可用时删除内存备用 func Del(ctx context.Context, key string) { client := redis.Client() if client == nil { + memoryDel(key) return } + memoryDel(key) if ctx == nil { ctx = context.Background() } @@ -106,12 +120,14 @@ func Del(ctx context.Context, key string) { } } -// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效 +// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),Redis 不可用时删除内存备用 func DelPattern(ctx context.Context, pattern string) { client := redis.Client() if client == nil { + memoryDelPattern(pattern) return } + memoryDelPattern(pattern) if ctx == nil { ctx = context.Background() } @@ -183,10 +199,13 @@ func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sp // ChapterContentTTL 章节正文 TTL,后台更新时主动 Del const ChapterContentTTL = 30 * time.Minute -// GetString 读取字符串(不经过 JSON,适合大文本 content) +// GetString 读取字符串(不经过 JSON,适合大文本 content),Redis 不可用时尝试内存备用 func GetString(ctx context.Context, key string) (string, bool) { client := redis.Client() if client == nil { + if data, ok := memoryGet(key); ok { + return string(data), true + } return "", false } if ctx == nil { @@ -196,15 +215,19 @@ func GetString(ctx context.Context, key string) (string, bool) { defer cancel() val, err := client.Get(ctx, key).Result() if err != nil { + if data, ok := memoryGet(key); ok { + return string(data), true + } return "", false } return val, true } -// SetString 写入字符串(不经过 JSON,适合大文本 content) +// SetString 写入字符串(不经过 JSON,适合大文本 content),Redis 不可用时写入内存备用 func SetString(ctx context.Context, key string, val string, ttl time.Duration) { client := redis.Client() if client == nil { + memorySet(key, []byte(val), ttl) return } if ctx == nil { @@ -213,7 +236,8 @@ func SetString(ctx context.Context, key string, val string, ttl time.Duration) { ctx, cancel := context.WithTimeout(ctx, defaultTimeout) defer cancel() if err := client.Set(ctx, key, val, ttl).Err(); err != nil { - log.Printf("cache.SetString %s: %v (非致命)", key, err) + log.Printf("cache.SetString %s: %v (非致命),已写入内存备用", key, err) + memorySet(key, []byte(val), ttl) } } diff --git a/soul-api/internal/cache/memory.go b/soul-api/internal/cache/memory.go new file mode 100644 index 00000000..1ba92ca5 --- /dev/null +++ b/soul-api/internal/cache/memory.go @@ -0,0 +1,52 @@ +package cache + +import ( + "strings" + "sync" + "time" +) + +// memoryFallback Redis 不可用时的内存备用缓存,保证服务可用 +var ( + memoryMu sync.RWMutex + memoryData = make(map[string]*memoryEntry) +) + +type memoryEntry struct { + Data []byte + Expiry time.Time +} + +func memoryGet(key string) ([]byte, bool) { + memoryMu.RLock() + defer memoryMu.RUnlock() + e, ok := memoryData[key] + if !ok || e == nil || time.Now().After(e.Expiry) { + return nil, false + } + return e.Data, true +} + +func memorySet(key string, data []byte, ttl time.Duration) { + memoryMu.Lock() + defer memoryMu.Unlock() + memoryData[key] = &memoryEntry{Data: data, Expiry: time.Now().Add(ttl)} +} + +func memoryDel(key string) { + memoryMu.Lock() + defer memoryMu.Unlock() + delete(memoryData, key) +} + +// memoryDelPattern 按前缀删除(pattern 如 soul:book:chapters-by-part:* 转为前缀 soul:book:chapters-by-part:) +func memoryDelPattern(pattern string) { + prefix := strings.TrimSuffix(pattern, "*") + memoryMu.Lock() + defer memoryMu.Unlock() + for k := range memoryData { + if strings.HasPrefix(k, prefix) { + delete(memoryData, k) + } + } +} diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index 059d4e02..9522f79c 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -13,7 +13,6 @@ import ( "soul-api/internal/config" "soul-api/internal/database" "soul-api/internal/model" - "soul-api/internal/redis" "github.com/gin-gonic/gin" ) @@ -92,6 +91,20 @@ func buildMiniprogramConfig() gin.H { } } } + // 价格:以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值 + var siteRow model.SystemConfig + if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 { + var siteVal map[string]interface{} + if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil { + cur := out["prices"].(gin.H) + if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 { + cur["section"] = v + } + if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 { + cur["fullbook"] = v + } + } + } // 好友优惠(用于 read 页展示优惠价) var refRow model.SystemConfig if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil { @@ -159,14 +172,8 @@ func GetPublicDBConfig(c *gin.Context) { // GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效 // 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时 -// 无 Redis 时直接返回数据库中的 auditMode 值,保证可用 +// Redis 不可用时 cache 包自动降级到内存备用 func GetAuditMode(c *gin.Context) { - // 无 Redis 时跳过缓存,直接返回数据库值 - if redis.Client() == nil { - auditMode := getAuditModeFromDB() - c.JSON(http.StatusOK, gin.H{"auditMode": auditMode}) - return - } var cached gin.H if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 { c.JSON(http.StatusOK, cached) diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go index 8dd34aa3..2d55943b 100644 --- a/soul-api/internal/handler/db_book.go +++ b/soul-api/internal/handler/db_book.go @@ -610,6 +610,38 @@ func DBBookAction(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)}) return } + // update-chapter-pricing:按篇+章批量更新该章下所有「节」行的 price / is_free(管理端章节统一定价) + if body.Action == "update-chapter-pricing" { + if body.PartID == "" || body.ChapterID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 partId 或 chapterId"}) + return + } + p := 1.0 + if body.Price != nil { + p = *body.Price + } + free := false + if body.IsFree != nil { + free = *body.IsFree + } + if free { + p = 0 + } + up := map[string]interface{}{ + "price": p, + "is_free": free, + } + res := db.Model(&model.Chapter{}).Where("part_id = ? AND chapter_id = ?", body.PartID, body.ChapterID).Updates(up) + if res.Error != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()}) + return + } + cache.InvalidateBookParts() + InvalidateChaptersByPartCache() + cache.InvalidateBookCache() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "已更新本章全部节的定价", "affected": res.RowsAffected}) + return + } if body.ID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"}) return diff --git a/soul-api/internal/redis/redis.go b/soul-api/internal/redis/redis.go index ed3e5412..14cf574e 100644 --- a/soul-api/internal/redis/redis.go +++ b/soul-api/internal/redis/redis.go @@ -21,6 +21,8 @@ func Init(url string) error { client = redis.NewClient(opt) ctx := context.Background() if err := client.Ping(ctx).Err(); err != nil { + client = nil // 连接失败时清空,避免后续使用超时;cache 将自动降级到内存备用 + log.Printf("redis: 连接失败,已降级到内存缓存(%v)", err) return err } log.Printf("redis: connected to %s", opt.Addr)