feat: 阅读页与章节预览 API;管理端内容页;book/h5_read;脚本与文档

- miniprogram: read 页与 member-detail/my;SOP 文档
- soul-api: chapter_preview、book/h5_read 调整;VIP 订单回填 SQL
- soul-admin: ContentPage、dist
- scripts: pull_from_baota;content_upload、gitignore、对话规则

Made-with: Cursor
This commit is contained in:
卡若
2026-03-26 20:08:43 +08:00
parent d6c8aabbe8
commit 6aa0d27da1
19 changed files with 1825 additions and 130 deletions

View File

@@ -12,7 +12,7 @@ alwaysApply: true
0. **默认零提问**:派对/Soul 相关开发、运维、脚本、填表链路,**禁止**反问用户「是否执行」「要不要」。缺信息则读本仓库代码与配置、用合理默认推进;**仅**验证码/密钥缺失/不可逆删除等无法代劳时,用**一句**说明缺什么。
1. **语言**:面向用户的说明、结论、按钮文案解释等,默认 **简体中文**。
2. **收尾**:每一轮对用户可见的助手回复,**最后一段**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm** 块,含五段:**🎯 目标·结果·达成率**单行30 字且含达成率 %)·**📌 过程** · **💡 反思** · **📝 总结** · **▶ 下一步执行**。复盘块内**禁止表格**。
2. **收尾**:每一轮对用户可见的助手回复,**最后一段**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm** 块,含五段:**🎯 目标·结果·达成率****单行一句50 字**,句内含达成率 **%**、**可负**;分发任务按视频号等**实际成功÷计划**计;**禁止** ➡️/📊 复述行与标准 ☯)·**📌 过程** · **💡 反思** · **📝 总结** · **▶ 下一步执行**。复盘块内**禁止表格**。细则以卡若 `运营中枢/参考资料/卡若复盘格式_固定规则.md` **v5.0** 为准。
3. **格式源**:与卡若 AI 仓库内 `运营中枢/参考资料/卡若复盘格式_固定规则.md` 保持同一种写法若当前工作区已挂载「卡若AI」目录修改复盘规则时以该文件为唯一真源。
4. **需求节奏**:仍服从「需求即执行」——先「好」再改代码再报结果;复盘块放在**全条回复最末**。
5. **直接执行**:用户说「直接做、别讲写了什么」时,**正文极短****复盘五块不可省**,可压缩过程为 12 条要点。

3
.gitignore vendored
View File

@@ -49,3 +49,6 @@ soul-api/soul-api-new
# 本地技能包临时打包目录
.tmp_skill_bundle/
# 从宝塔拉取的线上运行目录镜像(含 .env / 二进制,勿提交)
_server_live/

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse
import importlib.util
import re
import sys
from pathlib import Path
@@ -44,15 +45,17 @@ def strip_md_title_line(text: str) -> str:
def for_miniprogram_body(text: str) -> str:
"""上传 README少用 --- 分割线;正文内独立一行的 --- 改为空行分段"""
"""上传 README少用 --- 与 **独立一行的 --- 改为空行;去掉 **;压缩多余空行"""
out_lines: list[str] = []
for line in text.splitlines():
if line.strip() == "---":
out_lines.append("")
out_lines.append("")
else:
out_lines.append(line)
return "\n".join(out_lines).strip() + "\n"
body = "\n".join(out_lines)
body = body.replace("**", "")
body = re.sub(r"\n{3,}", "\n\n", body)
return body.strip() + "\n"
def next_10_id(cur) -> str:

View File

@@ -0,0 +1,57 @@
# 碎片时间小程序 · 认证任务众包 SOP
> 流程口径:平台只撮合任务与验收;账号来源、合规与平台规则由发起方自负,建议在合同/任务说明里写清。
---
## 角色
| 角色 | 说明 |
|:---|:---|
| 发起方(你) | 发任务、采购号、审核截图、打款 |
| 接单方 | 登录指定账号、完成认证、上传成功截图 |
---
## 步骤
**Step 0产品**
碎片时间小程序具备:发布任务、任务详情、接单/认领(或群内确认后你在后台绑定接单方)、上传凭证、发起方审核、标记完成/打款(或线下打款后在小程序确认)。
**Step 1生成任务**
在小程序里创建「认证类」任务:写清平台名称、认证类型、截止时间、赏金、**需上传的验收材料**(如:认证成功页截图)。
**Step 2丢到群里**
把任务链接或任务卡片发到目标群,说明谁接谁在小程序里认领(或私信你登记,由你在后台绑定该用户为执行人)。
**门禁:必须等到明确有人接单/认领后,再进入 Step 3。**
**Step 3采购认证号**
接单已锁定后,再到淘宝等平台购买符合任务要求的认证号(店铺、类目、是否已部分认证等按任务说明买)。
**Step 4交付登录信息**
**账号、密码、绑定手机号**(及验证码接收方式,若需要)通过约定渠道发给接单方;附 **极简认证流程**(要点的什么、上传什么、注意哪几条),避免长篇说明书。
**Step 5跟进认证**
接单方登录并按流程完成认证;页面显示 **已成功** 后,截取 **带成功状态** 的截图。
**Step 6上传与验收**
接单方在小程序任务里 **上传成功截图**;发起方核对截图与任务要求一致后 **审核通过并打款**,任务关闭。
---
## 验收清单(发起方勾选)
- [ ] 群内/小程序已锁定唯一接单方,未锁人不买号
- [ ] 账号信息与绑定手机已完整交给接单方
- [ ] 截图含明确「认证成功」或平台等价状态,且在任务约定平台内
- [ ] 赏金金额与打款方式与任务页一致
---
## 异常处理(简)
| 情况 | 处理 |
|:---|:---|
| 买号后接单方失联 | 任务重新挂群;原接单方在小程序取消认领规则需产品支持 |
| 认证失败 | 先区分是号问题还是操作问题;换号或换人按任务规则是否补赏金,事先写进任务说明 |
| 截图不清晰 | 退回补传,限次数写在任务里 |

View File

@@ -14,6 +14,9 @@ const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 从「我的」登录成功后 reLaunch 回本页并自动继续链接流程 */
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
@@ -22,10 +25,48 @@ Page({
const sb = app.globalData.statusBarHeight || 44
const myId = app.globalData.userInfo?.id
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
if (options.resumeLink === '1') this._resumeLinkAfterLoad = true
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
if (options.id) this.loadMember(options.id)
},
/** 朋友圈单页等受限环境 */
_isSinglePageMode() {
try {
if (app.globalData.isSinglePageMode) return true
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') return true
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) return true
} catch (e) {}
return false
},
/** 资料加载完成后:① resumeLink=1我的页登录回流② 本地存了待链接 memberId单页→完整版且已登录 */
_scheduleResumeLinkFlowIfNeeded() {
if (!this.data.member || !app.globalData.isLoggedIn) return
let trigger = false
if (this._resumeLinkAfterLoad) {
trigger = true
this._resumeLinkAfterLoad = false
} else {
try {
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '' && String(rid) === String(this.data.member.id)) {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
trigger = true
}
} catch (_) {}
}
if (!trigger) return
if (this._resumeLinkScheduled) return
this._resumeLinkScheduled = true
setTimeout(() => {
this._resumeLinkScheduled = false
this.startLinkFlow()
}, 400)
},
/** 本人名片:去完整编辑资料(单页) */
goMyProfileEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
@@ -92,6 +133,7 @@ Page({
}),
loading: false
})
this._scheduleResumeLinkFlowIfNeeded()
return
}
} catch (e) {}
@@ -101,7 +143,11 @@ Page({
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
if (res?.success && res.data) {
const d = Array.isArray(res.data) ? res.data[0] : res.data
if (d) { this.setData({ member: this.enrichAndFormat(d), loading: false }); return }
if (d) {
this.setData({ member: this.enrichAndFormat(d), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
@@ -125,11 +171,13 @@ Page({
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
this.setData({ loading: false })
this._resumeLinkAfterLoad = false
},
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
@@ -315,12 +363,41 @@ Page({
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
// 第一步:仅说明原因;第二步:引导登录(完整版去「我的」;单页引导底部「前往小程序」)
wx.showModal({
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
title: '提示',
content: `发起与「${nickname}」的链接前,需要先登录小程序账号。登录后智能助手与人工可协同协助您对接。`,
confirmText: '下一步',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
success: (r1) => {
if (!r1.confirm) return
const isSp = this._isSinglePageMode()
if (isSp) {
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.showModal({
title: '前往完整小程序',
content: '当前为预览模式。请轻触屏幕底部「前往小程序」进入完整版,在「我的」中登录;登录成功后将自动回到本页并继续链接流程。',
showCancel: false,
confirmText: '我知道了'
})
return
}
wx.showModal({
title: `链接「${nickname}`,
content: '是否前往「我的」页登录?',
confirmText: '去登录',
cancelText: '取消',
success: (r2) => {
if (!r2.confirm) return
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.switchTab({ url: '/pages/my/my' })
}
})
}
})
return
}

View File

@@ -871,6 +871,17 @@ Page({
this.initUserStatus()
this.setData({ showLoginModal: false })
wx.showToast({ title: '登录成功', icon: 'success' })
// 超级个体详情:未登录点链接 → 去登录 → 回详情页自动继续链接流程(与 member-detail LOGIN_RESUME_MEMBER_DETAIL_KEY 一致)
try {
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '') {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
wx.reLaunch({
url: '/pages/member-detail/member-detail?id=' + encodeURIComponent(String(rid).trim()) + '&resumeLink=1'
})
}
} catch (_) {}
},
// 点击菜单

View File

@@ -24,6 +24,28 @@ const soulBridge = require('../../utils/soulBridge.js')
const app = getApp()
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 与后端 defaultReadPreviewUI / read_preview_ui 配置键一致;占位符 {percent} {price} 在拉章节后替换 */
const READ_UI_DEFAULTS = {
singlePageUnlockTitle: '解锁完整内容',
singlePagePayButtonText: '支付 ¥{price} 解锁全文',
singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。',
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
'已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——',
timelineTitleSuffix: '(预览{percent}%',
}
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
function getContentParseConfig() {
const g = getApp().globalData || {}
@@ -186,6 +208,11 @@ Page({
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
// 未付费预览比例(与后端 unpaid_preview_percent / 章节 preview_percent 一致)
effectivePreviewPercent: 20,
// 付费墙 / 朋友圈文案(后端 read_preview_ui + 占位符已替换)
readUi: {},
},
_isLockedState(state) {
@@ -268,15 +295,61 @@ Page({
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
_formatPriceToken(n) {
const x = Number(n)
if (!isFinite(x)) return '1'
if (Math.abs(x - Math.round(x)) < 1e-9) return String(Math.round(x))
return String(Number(x.toFixed(2)))
},
/** 章节接口 meta预览比例 + 付费墙/朋友圈文案模板 */
_applyChapterMetaFromResponse(res) {
if (!res || typeof res !== 'object') return
const pct =
typeof res.unpaidPreviewPercent === 'number' ? res.unpaidPreviewPercent : 20
const priceNum =
res.price != null
? Number(res.price)
: this.data.section?.price != null
? Number(this.data.section.price)
: Number(this.data.sectionPrice || 1)
const priceTok = this._formatPriceToken(priceNum)
const base = { ...READ_UI_DEFAULTS }
if (res.readPreviewUi && typeof res.readPreviewUi === 'object') {
Object.keys(res.readPreviewUi).forEach((k) => {
const v = res.readPreviewUi[k]
if (typeof v === 'string' && v.trim()) base[k] = v.trim()
})
}
const filled = {}
Object.keys(base).forEach((k) => {
filled[k] = String(base[k] || '')
.replace(/\{percent\}/g, String(pct))
.replace(/\{price\}/g, priceTok)
})
this.setData({ effectivePreviewPercent: pct, readUi: filled })
},
/** 单页模式:点支付解锁 → 弹窗说明 → 展开底栏引导与箭头 */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
const ui = this.data.readUi || {}
wx.showModal({
title: ui.payTapModalTitle || '解锁说明',
content:
ui.payTapModalContent ||
'请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
showCancel: false,
confirmText: '知道了',
success: () => {
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
})
},
onShow() {
@@ -529,6 +602,7 @@ Page({
if (!res || !res.content) {
res = await app.request({ url: this._getChapterUrl({ id }), silent: true })
}
this._applyChapterMetaFromResponse(res)
const section = {
id: res.id || id,
title: res.sectionTitle || res.title || this.getSectionTitle(id),
@@ -680,6 +754,7 @@ Page({
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
async setChapterContent(res) {
this._applyChapterMetaFromResponse(res)
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
@@ -1068,14 +1143,12 @@ Page({
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#卡若创业派对 #真实商业故事`
const ui = this.data.readUi || {}
const paras = (this.data.previewParagraphs || []).filter(Boolean).join('\n\n')
const footer = ui.momentsClipboardFooter || ''
const copyText = paras
? `${title}\n\n${paras}${footer}`
: `${title}${footer || `\n\n—— 预览约 ${this.data.effectivePreviewPercent ?? 20}% ,搜「卡若创业派对」小程序阅读全文 ——`}`
wx.setClipboardData({
data: copyText,
success: () => {
@@ -1084,8 +1157,10 @@ Page({
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
title: ui.momentsModalTitle || '分享到朋友圈',
content:
ui.momentsModalContent ||
'已复制发圈文案。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -1099,15 +1174,18 @@ Page({
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data
const { section, sectionId, sectionMid, chapterTitle, readUi } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const query = ref ? `${q}&ref=${ref}` : q
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: '卡若创业派对 - 真实商业故事'
return { title, query }
const base = articleTitle || '卡若创业派对 - 真实商业故事'
const suffix = (readUi && readUi.timelineTitleSuffix) || ''
let full = base + suffix
if (full.length > 30) {
full = full.slice(0, 30) + '…'
}
return { title: full, query }
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)

View File

@@ -106,7 +106,7 @@
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
<text class="share-tip-text">{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}}</text>
</view>
</view>
@@ -132,27 +132,30 @@
<view class="fade-mask"></view>
</view>
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,登录并支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 后阅读全文</text>
<text class="paywall-desc">{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
@@ -162,7 +165,7 @@
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
</block>
</view>
@@ -216,26 +219,30 @@
<view class="fade-mask"></view>
</view>
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,购买后继续阅读</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc">{{readUi.fullUnlockDesc || '可先上滑阅读预览'}}</text>
<text class="paywall-subdesc" wx:if="{{readUi.fullLockedProgressText}}">{{readUi.fullLockedProgressText}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
@@ -253,7 +260,7 @@
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>

View File

@@ -327,6 +327,41 @@
margin-bottom: 48rpx;
}
.paywall-subdesc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.52);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.5;
padding: 0 16rpx;
}
.preview-percent-row {
margin: 16rpx 0 8rpx;
display: flex;
justify-content: center;
}
.preview-percent-badge {
font-size: 24rpx;
color: #00CED1;
border: 2rpx solid rgba(0, 206, 209, 0.35);
border-radius: 999rpx;
padding: 8rpx 24rpx;
background: rgba(0, 206, 209, 0.08);
}
.purchase-btn--fulltext {
justify-content: center;
}
.purchase-btn--fulltext .btn-label--block {
flex: 1;
text-align: center;
width: 100%;
}
/* ===== 购买选项 ===== */
.purchase-options {
display: flex;

153
scripts/pull_from_baota.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从宝塔正式机拉取线上运行目录到本地镜像(与 soul-api/master.py、soul-admin/master.py 同源 SSH 配置)。
说明:
- 服务器上一般是「二进制 + .env + 日志」与「静态 dist」不包含完整 Go/React 源码。
- 默认解压到仓库根目录 _server_live/soul-api、_server_live/soul-admin不覆盖本地工程源码。
环境变量与 master.py 一致DEPLOY_HOST、DEPLOY_USER、DEPLOY_PASSWORD、DEPLOY_SSH_KEY、
DEPLOY_PROJECT_PATH、DEPLOY_BASE_PATH。
"""
from __future__ import print_function
import argparse
import importlib.util
import os
import shutil
import sys
import tarfile
import tempfile
import threading
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SOUL_API_DIR = os.path.join(ROOT, "soul-api")
def _load_api_master():
path = os.path.join(SOUL_API_DIR, "master.py")
spec = importlib.util.spec_from_file_location("soul_api_deploy_master", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _pull_dir_tar(client, remote_dir, local_dir, mod, timeout=600):
"""远端 tar czf 流式下载并解压到 local_dir。"""
import shlex
remote_q = shlex.quote(remote_dir)
cmd = "tar czf - -C %s . 2>/dev/null" % remote_q
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
err_holder = []
def _drain():
try:
err_holder.append(stderr.read())
except Exception:
pass
t = threading.Thread(target=_drain)
t.daemon = True
t.start()
fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz")
os.close(fd)
try:
with open(tmp_path, "wb") as out:
while True:
chunk = stdout.read(256 * 1024)
if not chunk:
break
out.write(chunk)
t.join(timeout=5)
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
print(" [警告] 远端 tar 退出码: %s" % exit_status)
if os.path.isdir(local_dir):
shutil.rmtree(local_dir)
os.makedirs(local_dir, exist_ok=True)
with tarfile.open(tmp_path, "r:gz") as tf:
tf.extractall(local_dir)
print(" [成功] 已同步到: %s" % local_dir)
return True
finally:
try:
os.remove(tmp_path)
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description="从宝塔拉取 soul-api / soul-admin 线上目录")
parser.add_argument("--api-only", action="store_true", help="仅拉 soul-api")
parser.add_argument("--admin-only", action="store_true", help="仅拉 soul-admin")
args = parser.parse_args()
mod = _load_api_master()
cfg = mod.get_cfg()
if not cfg.get("password") and not cfg.get("ssh_key"):
print("[失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return 1
pull_api = not args.admin_only
pull_admin = not args.api_only
if args.api_only and args.admin_only:
print("[失败] 不能同时指定 --api-only 与 --admin-only")
return 1
client = None
try:
client = mod._connect_ssh(cfg)
live_root = os.path.join(ROOT, "_server_live")
os.makedirs(live_root, exist_ok=True)
print("=" * 60)
print(" 从宝塔拉取线上目录 → %s" % live_root)
print(" 主机: %s@%s:%s" % (cfg["user"], cfg["host"], mod.DEFAULT_SSH_PORT))
print("=" * 60)
if pull_api:
print("[1] soul-api: %s" % cfg["project_path"])
_pull_dir_tar(
client,
cfg["project_path"],
os.path.join(live_root, "soul-api"),
mod,
)
if pull_admin:
admin_base = os.environ.get("DEPLOY_BASE_PATH", "/www/wwwroot/self/soul-admin").rstrip("/")
print("[2] soul-admin: %s" % admin_base)
# 复用同一连接;若仅拉 admin上面未开新连接也行
if not pull_api:
pass
_pull_dir_tar(
client,
admin_base,
os.path.join(live_root, "soul-admin"),
mod,
)
print("")
print(" 完成。镜像根目录: %s" % live_root)
return 0
except Exception as e:
print("[失败] %s" % e)
import traceback
traceback.print_exc()
return 1
finally:
if client:
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main() or 0)

1010
soul-admin/dist/assets/index-CXKq85jN.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-BRyXRtx1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfljfNs2.css">
<script type="module" crossorigin src="/assets/index-CXKq85jN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfljfNs2.css">
</head>
<body>
<div id="root"></div>
<div id="root"></div>
</body>
</html>

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
@@ -57,6 +58,33 @@ import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal'
import { getPersonDetail } from '@/api/ckb'
import { apiUrl } from '@/api/client'
/** 与 soul-api mergeReadPreviewUI 默认键一致;小程序用 {percent} {price} 占位符替换 */
const READ_PREVIEW_UI_TEMPLATE = JSON.stringify(
{
singlePageUnlockTitle: '解锁完整内容',
singlePagePayButtonText: '支付 ¥{price} 解锁全文',
singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。',
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
'已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
momentsClipboardFooter:
'\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——',
timelineTitleSuffix: '(预览{percent}%',
},
null,
2,
)
interface SectionListItem {
id: string
mid?: number
@@ -261,6 +289,9 @@ export function ContentPage() {
const [previewPercent, setPreviewPercent] = useState(20)
const [previewPercentLoading, setPreviewPercentLoading] = useState(false)
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const [readPreviewUiJson, setReadPreviewUiJson] = useState(READ_PREVIEW_UI_TEMPLATE)
const [readPreviewUiLoading, setReadPreviewUiLoading] = useState(false)
const [readPreviewUiSaving, setReadPreviewUiSaving] = useState(false)
const [persons, setPersons] = useState<PersonItem[]>([])
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
const [linkTagList, setLinkTagList] = useState<LinkTagItem[]>([])
@@ -801,15 +832,60 @@ export function ContentPage() {
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
}
const loadReadPreviewUi = useCallback(async () => {
setReadPreviewUiLoading(true)
try {
const data = await get<{ success?: boolean; data?: unknown }>(
'/api/db/config/full?key=read_preview_ui',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { data?: unknown }).data
if (d != null && typeof d === 'object' && !Array.isArray(d) && Object.keys(d as object).length > 0) {
setReadPreviewUiJson(JSON.stringify(d, null, 2))
} else {
setReadPreviewUiJson(READ_PREVIEW_UI_TEMPLATE)
}
} catch {
setReadPreviewUiJson(READ_PREVIEW_UI_TEMPLATE)
} finally {
setReadPreviewUiLoading(false)
}
}, [])
const handleSaveReadPreviewUi = async () => {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(readPreviewUiJson) as Record<string, unknown>
} catch {
toast.error('JSON 格式错误,请检查括号与引号')
return
}
setReadPreviewUiSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'read_preview_ui',
value: parsed,
description: '阅读页/朋友圈付费墙与分享文案(占位符 {percent} {price}',
})
if (res && (res as { success?: boolean }).success !== false) toast.success('阅读页文案已保存')
else toast.error('保存失败: ' + ((res as { error?: string }).error || ''))
} catch {
toast.error('保存失败')
} finally {
setReadPreviewUiSaving(false)
}
}
useEffect(() => {
loadPinnedSections()
loadPreviewPercent()
loadReadPreviewUi()
loadPersons()
loadLinkTags()
loadCkbLeadCounts()
loadLinkedMps()
loadCkbWebhookUrl()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl])
}, [loadPinnedSections, loadPreviewPercent, loadReadPreviewUi, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl])
useEffect(() => {
loadLinkTagList()
@@ -2519,7 +2595,42 @@ export function ContentPage() {
>
{previewPercentSaving ? '保存中...' : '保存'}
</Button>
<span className="text-xs text-gray-500"> {previewPercent}% </span>
<span className="text-xs text-gray-500"> {previewPercent}% %</span>
</div>
<div className="mt-6 space-y-2">
<Label className="text-gray-400 text-sm"> / JSON</Label>
<p className="text-xs text-gray-500">
<code className="text-gray-400">{'{percent}'}</code>
<code className="text-gray-400">{'{price}'}</code>
</p>
<Textarea
className="bg-[#0a1628] border-gray-700 text-gray-200 font-mono text-xs min-h-[280px]"
value={readPreviewUiJson}
onChange={(e) => setReadPreviewUiJson(e.target.value)}
disabled={readPreviewUiLoading}
spellCheck={false}
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-300"
onClick={() => loadReadPreviewUi()}
disabled={readPreviewUiLoading}
>
</Button>
<Button
type="button"
size="sm"
onClick={handleSaveReadPreviewUi}
disabled={readPreviewUiSaving || readPreviewUiLoading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{readPreviewUiSaving ? '保存中...' : '保存文案配置'}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -10,7 +10,6 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"soul-api/internal/cache"
"soul-api/internal/database"
@@ -643,44 +642,6 @@ func getUnpaidPreviewPercent(db *gorm.DB) int {
return 20
}
// effectivePreviewPercent 章节 preview_percent 优先1~100否则用全局 unpaid_preview_percent
func effectivePreviewPercent(db *gorm.DB, ch *model.Chapter) int {
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return getUnpaidPreviewPercent(db)
}
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit]) + "\n\n……"
}
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
@@ -723,13 +684,12 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
hasFullAccess := isFree || checkUserChapterAccess(db, userID, ch.ID, isPremium)
var returnContent string
// 未解锁:正文截取用「章节覆盖 全局」;响应里顶层 previewPercent 仅表示全局默认data.previewPercent 表示章节私有model omitempty
var effectiveUnpaidPreviewPercent int
// 未解锁:正文截取见 chapter_preview.goHTML 剥标签后与 H5 一致
if hasFullAccess {
returnContent = ch.Content
} else {
effectiveUnpaidPreviewPercent = effectivePreviewPercent(db, &ch)
returnContent = previewContent(ch.Content, effectiveUnpaidPreviewPercent)
percent := chapterPreviewPercent(db, &ch)
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
@@ -739,17 +699,20 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
chForResponse.ChapterTitle = sanitizeChapterTitleField(ch.ChapterTitle)
chForResponse.SectionTitle = sanitizeChapterTitleField(ch.SectionTitle)
effectivePct := chapterPreviewPercent(db, &ch)
out := gin.H{
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"unpaidPreviewPercent": effectivePct,
"readPreviewUi": mergeReadPreviewUI(db),
}
if !hasFullAccess {
out["previewPercent"] = getUnpaidPreviewPercent(db)

View File

@@ -0,0 +1,121 @@
package handler
import (
"encoding/json"
"html"
"regexp"
"strings"
"unicode/utf8"
"soul-api/internal/model"
"gorm.io/gorm"
)
var (
reHTMLScript = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
reHTMLStyle = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
reHTMLBr = regexp.MustCompile(`(?i)<\s*br\s*/?>`)
reHTMLPClose = regexp.MustCompile(`(?i)</\s*p\s*>`)
reHTMLTags = regexp.MustCompile(`<[^>]+>`)
)
// stripHTMLToPlainPreview 将正文 HTML 转为纯文本再截取,避免预览截在 <img 中间导致用户看到半截标签
func stripHTMLToPlainPreview(s string) string {
s = reHTMLScript.ReplaceAllString(s, " ")
s = reHTMLStyle.ReplaceAllString(s, " ")
s = reHTMLBr.ReplaceAllString(s, "\n")
s = reHTMLPClose.ReplaceAllString(s, "\n")
s = reHTMLTags.ReplaceAllString(s, "")
s = html.UnescapeString(s)
s = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(s)
return strings.TrimSpace(s)
}
// chapterPreviewPercent 章节单独 preview_percent 覆盖全局 unpaid_preview_percent
func chapterPreviewPercent(db *gorm.DB, ch *model.Chapter) int {
g := getUnpaidPreviewPercent(db)
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return g
}
// previewContent 取内容的前 percent%(按纯文本 rune 计;原 HTML 先剥标签),与 h5 落地页一致
func previewContent(content string, percent int) string {
work := content
if strings.Contains(content, "<") && strings.Contains(content, ">") {
work = stripHTMLToPlainPreview(content)
}
total := utf8.RuneCountInString(work)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
runes := []rune(work)
return string(runes[:limit]) + "\n\n……"
}
func defaultReadPreviewUI() map[string]string {
return map[string]string{
"singlePageUnlockTitle": "解锁完整内容",
"singlePagePayButtonText": "支付 ¥{price} 解锁全文",
"singlePageExpandedHint": "预览页不能直接付款,务必先点底栏「前往小程序」。",
"payTapModalTitle": "解锁说明",
"payTapModalContent": "全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。",
"fullUnlockTitle": "解锁完整内容",
"fullUnlockDesc": "可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明",
"fullLockedProgressText": "已阅读约 {percent}% ,购买后继续阅读",
"fullPaywallTip": "转发给需要的人,一起学习还能赚佣金",
"notLoginUnlockDesc": "已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文",
"notLoginPaywallTip": "分享给好友一起学习,还能赚取佣金",
"shareTipLine": "好友经你分享购买,你可获得约 90% 收益",
"momentsModalTitle": "分享到朋友圈",
"momentsModalContent": "已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。",
"momentsClipboardFooter": "\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——",
"timelineTitleSuffix": "(预览{percent}%",
}
}
// mergeReadPreviewUI 合并 system_config.read_preview_uiJSON 对象)与默认文案;占位符 {percent}/{price} 由小程序替换
func mergeReadPreviewUI(db *gorm.DB) map[string]string {
out := defaultReadPreviewUI()
var row model.SystemConfig
if err := db.Where("config_key = ?", "read_preview_ui").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
out[k] = strings.TrimSpace(t)
}
default:
// 兼容数字等被误存:忽略非字符串
}
}
return out
}

View File

@@ -5,7 +5,6 @@ import (
"html"
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
@@ -37,8 +36,8 @@ func H5ReadPage(c *gin.Context) {
return
}
percent := effectivePreviewPercent(db, &ch)
preview := h5PreviewContent(ch.Content, percent)
percent := chapterPreviewPercent(db, &ch)
preview := previewContent(ch.Content, percent)
title := ch.SectionTitle
if title == "" {
@@ -74,28 +73,6 @@ func H5ReadPage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(pageHTML))
}
func h5PreviewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit])
}
func h5Error(msg string) string {
return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>提示</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f1923;color:#ccc;}</style>

View File

@@ -0,0 +1,12 @@
-- =============================================================================
-- 说明:本目录拆成两个脚本,请勿直接执行本文件全文。
--
-- 1) 先执行预览backfill-vip-orders-preview.sql
-- 2) 确认人数与金额后再执行backfill-vip-orders-insert.sql
--
-- 背景:数据概览的总收入/订单数来自 orders 表 status IN (paid,completed,success)。
-- 管理端开通的超级个体users.is_vip=1若没有 product_type=vip 的已付订单,则不会计入。
-- 插入订单后transaction_id=MANUAL_BACKFILL_VIPpayment_method=manual便于与真实微信单区分。
-- =============================================================================
SELECT '请运行 backfill-vip-orders-preview.sql 与 backfill-vip-orders-insert.sql' AS hint;

View File

@@ -0,0 +1,44 @@
-- 插入:为符合条件的超级个体用户各补一条 vip ¥1980 已付订单(仅无 vip 已付记录者)
-- 执行前请先跑 backfill-vip-orders-preview.sql并备份 orders 表
INSERT INTO orders (
id,
order_sn,
user_id,
open_id,
product_type,
product_id,
amount,
description,
status,
payment_method,
transaction_id,
pay_time,
created_at,
updated_at
)
SELECT
CONCAT('BFV', MD5(CONCAT(u.id, ':vip1980:backfill'))),
CONCAT('BFV', MD5(CONCAT(u.id, ':vip1980:backfill'))),
u.id,
COALESCE(u.open_id, ''),
'vip',
'vip_annual',
1980.00,
'卡若创业派对VIP年度会员365天【数据补录与超级个体权益对齐】',
'paid',
'manual',
'MANUAL_BACKFILL_VIP',
COALESCE(u.vip_activated_at, u.updated_at, u.created_at, NOW()),
NOW(),
NOW()
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
);

View File

@@ -0,0 +1,33 @@
-- 预览:将补多少超级个体用户、合计增加多少营收(每人一条 ¥1980 vip 已付订单)
-- 条件:当前仍为 VIPis_vip=1 且未过期或未填过期视为仍有效)且尚无 vip 已付订单
SELECT
COUNT(*) AS users_to_backfill,
COUNT(*) * 1980.00 AS revenue_to_add_yuan
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
);
SELECT
u.id,
u.nickname,
u.vip_activated_at,
u.vip_expire_date
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
)
ORDER BY u.vip_activated_at DESC, u.id;