feat: 内容管理第5批优化 - Bug修复 + 分享功能 + 代付功能

1. Bug修复:
   - 修复Markdown星号/下划线在小程序端原样显示问题(markdownToHtml增加__和_支持,contentParser增加Markdown格式剥离)
   - 修复@提及无反应(MentionSuggestion使用ref保持persons最新值,解决闭包捕获空数组问题)
   - 修复#链接标签点击"未找到小程序配置"(增加appId直接跳转降级路径)

2. 分享功能优化:
   - "分享到朋友圈"改为"分享给好友"(open-type从shareTimeline改为share)
   - 90%收益提示移到分享按钮下方
   - 阅读20%后向上滑动弹出分享浮层提示(4秒自动消失)

3. 代付功能:
   - 后端:新增UserBalance/BalanceTransaction/GiftUnlock三个模型
   - 后端:新增8个余额相关API(查询/充值/充值确认/代付/领取/退款/交易记录/礼物信息)
   - 小程序:阅读页新增"代付分享"按钮,支持用余额为好友解锁章节
   - 分享链接携带gift参数,好友打开自动领取解锁

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 09:20:27 +08:00
parent 8778a42429
commit 991e17698c
260 changed files with 26780 additions and 1026 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
20260226一场.zip Normal file

Binary file not shown.

View File

@@ -431,6 +431,7 @@ App({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',

View File

@@ -57,7 +57,10 @@
]
},
"usingComponents": {},
"navigateToMiniProgramAppIdList": [],
"navigateToMiniProgramAppIdList": [
"wx6489c26045912fe1",
"wx3d15ed02e98b04e3"
],
"__usePrivacyCheck__": true,
"lazyCodeLoading": "requiredComponents",
"style": "v2",

View File

@@ -108,13 +108,15 @@ Page({
this.updateUserStatus()
},
// 初始化数据:首次进页面并行异步加载,加快首屏展示
initData() {
this.setData({ loading: false })
this.loadBookData()
this.loadFeaturedFromServer()
this.loadSuperMembers()
this.loadLatestChapters()
Promise.all([
this.loadBookData(),
this.loadFeaturedFromServer(),
this.loadSuperMembers(),
this.loadLatestChapters()
]).finally(() => {
this.setData({ loading: false })
})
},
async loadSuperMembers() {

View File

@@ -6,6 +6,7 @@ Page({
title: '链接预览',
statusBarHeight: 44,
navBarHeight: 88,
loadError: false,
},
onLoad(options) {
@@ -19,6 +20,26 @@ Page({
})
},
onWebViewError() {
this.setData({ loadError: true })
wx.showModal({
title: '无法在小程序内打开',
content: '该链接无法在小程序内预览,是否复制链接到浏览器打开?',
confirmText: '复制链接',
cancelText: '返回',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: this.data.url,
success: () => wx.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none', duration: 2000 }),
})
} else {
this.goBack()
}
},
})
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {

View File

@@ -18,8 +18,14 @@
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
<!-- 链接预览区域 -->
<view class="webview-wrap" wx:if="{{url}}">
<web-view src="{{url}}"></web-view>
<view class="webview-wrap" wx:if="{{url && !loadError}}">
<web-view src="{{url}}" binderror="onWebViewError"></web-view>
</view>
<view class="error-wrap" wx:elif="{{loadError}}">
<text class="error-text">该链接无法在小程序内预览</text>
<view class="error-btn" bindtap="copyLink">
<text class="error-btn-text">复制链接到浏览器打开</text>
</view>
</view>
<view class="empty-wrap" wx:else>
<text class="empty-text">暂无链接地址</text>

View File

@@ -81,3 +81,27 @@ web-view {
font-size: 26rpx;
}
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx;
}
.error-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.error-btn {
padding: 16rpx 40rpx;
border-radius: 999rpx;
background: #38bdac;
}
.error-btn-text {
font-size: 26rpx;
color: #fff;
}

View File

@@ -70,6 +70,9 @@ Page({
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
showShareTip: false,
_shareTipShown: false,
_lastScrollTop: 0,
// 章节 mid扫码/海报分享用,便于分享 path 带 mid
sectionMid: null
@@ -97,8 +100,6 @@ Page({
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
console.log("页面:",mid);
// mid 有值但无 id 时,从 bookData 或 API 解析 id
if (mid && !id) {
const bookData = app.globalData.bookData || []
@@ -138,6 +139,11 @@ Page({
app.handleReferralCode({ query: { ref } })
}
const giftCode = options.gift || ''
if (giftCode) {
this._pendingGiftCode = giftCode
}
try {
const config = await accessManager.fetchLatestConfig()
this.setData({
@@ -160,6 +166,13 @@ Page({
// 加载内容(复用已拉取的章节数据,避免二次请求)
await this.loadContent(id, accessState, chapterRes)
// 自动领取礼物码(代付解锁)
if (this._pendingGiftCode && !canAccess && app.globalData.isLoggedIn) {
await this._redeemGiftCode(this._pendingGiftCode)
this._pendingGiftCode = null
return
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
@@ -184,6 +197,11 @@ Page({
return
}
const currentScrollTop = e.scrollTop || 0
const lastScrollTop = this.data._lastScrollTop || 0
const isScrollingDown = currentScrollTop < lastScrollTop
this.setData({ _lastScrollTop: currentScrollTop })
// 获取滚动信息并更新追踪器
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
@@ -202,6 +220,12 @@ Page({
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
: 0
this.setData({ readingProgress: progress })
// 阅读超过20%且向上滑动时,弹出一次分享提示
if (progress >= 20 && isScrollingDown && !this.data._shareTipShown) {
this.setData({ showShareTip: true, _shareTipShown: true })
setTimeout(() => { this.setData({ showShareTip: false }) }, 4000)
}
// 更新阅读追踪器(记录最大进度、判断是否读完)
readingTracker.updateProgress(scrollInfo)
@@ -492,33 +516,38 @@ Page({
}
}
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
// CKB 类型:走「链接卡若」留资流程(与首页 onLinkKaruo 一致)
if (tagType === 'ckb') {
// 触发通用加好友(无特定 personId使用全局 CKB Key
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
this._doCkbLead(label)
return
}
// 小程序类型:用密钥查 linkedMiniprograms 得 appId再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
// 小程序类型:查 linkedMiniprograms 得 appId降级直接用 mpKey/appId 字段
if (tagType === 'miniprogram') {
let appId = (e.currentTarget.dataset.appId || '').trim()
if (!mpKey && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) mpKey = cached.mpKey || ''
if (cached) {
mpKey = cached.mpKey || ''
if (!appId && cached.appId) appId = cached.appId
}
}
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
if (linked && linked.appId) {
const targetAppId = (linked && linked.appId) ? linked.appId : (appId || mpKey || '')
if (targetAppId) {
wx.navigateToMiniProgram({
appId: linked.appId,
path: pagePath || linked.path || '',
appId: targetAppId,
path: pagePath || (linked && linked.path) || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
console.warn('[LinkTag] 小程序跳转失败:', err)
wx.showToast({ title: '跳转失败,请检查小程序配置', icon: 'none' })
},
})
return
}
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
wx.showToast({ title: '未配置关联小程序', icon: 'none' })
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
@@ -638,6 +667,76 @@ Page({
}
},
async _doCkbLead(label) {
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再链接',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const userId = app.globalData.userInfo.id
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
}
} catch (e) {}
}
if (!phone && !wechatId) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号或微信号,以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
source: 'article_ckb_tag',
tagLabel: label || undefined
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
// 分享弹窗
showShare() {
this.setData({ showShareModal: true })
@@ -687,14 +786,19 @@ Page({
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const shareTitle = section?.title
const giftCode = this._giftCodeToShare || ''
this._giftCodeToShare = null
let shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
return {
title: shareTitle,
path: ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
// 不设置 imageUrl使用当前阅读页截图作为分享卡片中间图片
}
if (giftCode) shareTitle = `🎁 好友已为你解锁:${section?.title || '精选文章'}`
let path = `/pages/read/read?${q}`
if (ref) path += `&ref=${ref}`
if (giftCode) path += `&gift=${giftCode}`
return { title: shareTitle, path }
},
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
@@ -1357,7 +1461,84 @@ Page({
closePosterModal() {
this.setData({ showPosterModal: false })
},
closeShareTip() {
this.setData({ showShareTip: false })
},
// 代付分享:用余额帮好友解锁当前章节
async handleGiftPay() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } })
return
}
const sectionId = this.data.sectionId
const userId = app.globalData.userInfo.id
const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1)
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
wx.showModal({
title: '代付分享',
content: `为好友代付本章 ¥${price}\n当前余额: ¥${balance.toFixed(2)}\n${balance < price ? '余额不足,请先充值' : '确认后将从余额扣除'}`,
confirmText: balance >= price ? '确认代付' : '去充值',
success: async (res) => {
if (!res.confirm) return
if (balance < price) {
wx.navigateTo({ url: '/pages/wallet/wallet' })
return
}
wx.showLoading({ title: '处理中...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
const giftCode = giftRes.data.giftCode
wx.showModal({
title: '代付成功!',
content: `已为好友代付 ¥${price},分享链接后好友可免费阅读`,
confirmText: '分享给好友',
success: (r) => {
if (r.confirm) {
this._giftCodeToShare = giftCode
wx.shareAppMessage()
}
}
})
} else {
wx.showToast({ title: (giftRes && giftRes.error) || '代付失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '代付失败', icon: 'none' })
}
}
})
},
// 领取礼物码解锁
async _redeemGiftCode(giftCode) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
try {
const res = await app.request({
url: '/api/miniprogram/balance/gift/redeem',
method: 'POST',
data: { giftCode, receiverId: app.globalData.userInfo.id }
})
if (res && res.data) {
wx.showToast({ title: '好友已为你解锁!', icon: 'success' })
this.onLoad({ id: this.data.sectionId })
}
} catch (e) {
console.warn('[Gift] 领取失败:', e)
}
},
// 保存海报到相册
savePoster() {
wx.canvasToTempFilePath({

View File

@@ -25,7 +25,7 @@
<!-- 阅读内容 -->
<view class="read-content">
<!-- 章节标题 -->
<view class="chapter-header">
<view class="chapter-header" wx:if="{{section}}">
<view class="chapter-meta">
<text class="chapter-id">{{section.id}}</text>
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
@@ -85,15 +85,22 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享到朋友圈</text>
<text class="action-text-small">分享给好友</text>
</button>
<view class="action-btn-inline btn-gift-inline" bindtap="handleGiftPay">
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>
</view>
</view>
<view class="share-tip-inline">
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
</view>
</view>
</view>
@@ -242,6 +249,14 @@
</view>
</view>
<!-- 分享提示浮层阅读20%后下拉触发) -->
<view class="share-float-tip {{showShareTip ? 'show' : ''}}" wx:if="{{showShareTip}}">
<text class="share-float-icon">💰</text>
<text class="share-float-text">分享给好友,好友购买你可获得 90% 收益</text>
<button class="share-float-btn" open-type="share">立即分享</button>
<view class="share-float-close" bindtap="closeShareTip">✕</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">

View File

@@ -1003,3 +1003,77 @@
display: block;
}
/* ===== 分享提示文字(底部导航上方) ===== */
.share-tip-inline {
text-align: center;
margin-top: 16rpx;
}
.share-tip-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 分享浮层提示阅读20%触发) ===== */
.share-float-tip {
position: fixed;
top: 180rpx;
left: 40rpx;
right: 40rpx;
background: linear-gradient(135deg, #1a3a4a 0%, #0d2533 100%);
border: 1rpx solid rgba(0, 206, 209, 0.3);
border-radius: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
z-index: 10000;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(-40rpx);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.share-float-tip.show {
opacity: 1;
transform: translateY(0);
}
.share-float-icon {
font-size: 40rpx;
flex-shrink: 0;
}
.share-float-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
flex: 1;
}
.share-float-btn {
background: linear-gradient(135deg, #00CED1, #20B2AA) !important;
color: #fff !important;
font-size: 24rpx;
padding: 10rpx 28rpx;
border-radius: 32rpx;
border: none;
flex-shrink: 0;
line-height: 1.5;
}
.share-float-btn::after {
border: none;
}
.share-float-close {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
padding: 8rpx;
flex-shrink: 0;
}
/* ===== 代付分享按钮 ===== */
.btn-gift-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 12rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 165, 0, 0.1);
border: 1rpx solid rgba(255, 165, 0, 0.3);
}

View File

@@ -27,8 +27,8 @@
"name": "唤醒",
"pathName": "pages/read/read",
"query": "mid=209",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/my/my",

View File

@@ -144,9 +144,29 @@ function parseHtmlToSegments(html) {
return { lines, segments }
}
/** 纯文本按行解析(无 HTML 标签) */
/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
function stripMarkdownFormatting(text) {
if (!text) return text
let s = text
s = s.replace(/^#{1,6}\s+/gm, '')
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
s = s.replace(/__(.+?)__/g, '$1')
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1')
s = s.replace(/~~(.+?)~~/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/^>\s+/gm, '')
s = s.replace(/^---$/gm, '')
s = s.replace(/^\* /gm, '• ')
s = s.replace(/^- /gm, '• ')
s = s.replace(/^\d+\.\s/gm, '')
return s
}
/** 纯文本/Markdown 按行解析 */
function parsePlainTextToSegments(text) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const cleaned = stripMarkdownFormatting(text)
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => [{ type: 'text', text: line }])
return { lines, segments }
}

4
scripts/.env.feishu Normal file
View File

@@ -0,0 +1,4 @@
# 飞书应用凭证(卡若私域)- 勿提交到公开仓库
FEISHU_APP_ID=cli_a48818290ef8100d
FEISHU_APP_SECRET=dhjU0qWd5AzicGWTf4cTqhCWJOrnuCk4
FEISHU_WIKI_NODE_TOKEN=FNP6wdvNKij7yMkb3xCce0CYnpd

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""批量去掉重复标题并重新上传到小程序。content_upload 已内置 strip 首行 # 标题。"""
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BASE_2026 = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/2026每日派对干货")
BASE_9 = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例")
# (id, title, md_file_path relative to BASE_2026 or "9" for BASE_9)
CHAPTERS_2026 = [
("10.01", "第102场今年第一个红包你发给谁", BASE_2026 / "第102场今年第一个红包你发给谁.md"),
("10.02", "第103场号商、某客与炸房", BASE_2026 / "第103场号商、某客与炸房.md"),
("10.03", "第105场创业社群、直播带货与程序员", BASE_2026 / "第105场创业社群、直播带货与程序员.md"),
("10.04", "第104场婚恋、AI客服与一个微信", BASE_2026 / "第104场婚恋、AI客服与一个微信.md"),
("10.05", "第107场性格、陪伴经济与本地AI", BASE_2026 / "第107场性格、陪伴经济与本地AI.md"),
("10.06", "第108场Soul场观400等于抖音1万", BASE_2026 / "第108场Soul场观400等于抖音1万.md"),
("10.07", "第111场平台规则变了怎么办", BASE_2026 / "第111场平台规则变了怎么办.md"),
("10.08", "第110场Soul变现逻辑全程公开", BASE_2026 / "第110场Soul变现逻辑全程公开.md"),
("10.09", "第112场一个人起头维权挣了大半套房", BASE_2026 / "第112场一个人起头维权挣了大半套房.md"),
("10.10", "第113场不会选择怎么办", BASE_2026 / "第113场不会选择怎么办.md"),
("10.11", "第114场人跟人差别以前没 AI 差 100 倍,有 AI 差 1 万倍。", BASE_2026 / "第114场-人跟人差别,以前没 AI 差 100 倍,有 AI 差 1 万倍。.md"),
("10.12", "第115场一天改变可控的事先做", BASE_2026 / "第115场一天改变可控的事先做.md"),
("10.13", "第116场钱是大风刮来的怎么抓住", BASE_2026 / "第116场钱是大风刮来的怎么抓住.md"),
("10.14", "第117场流水百万挣八千你还干不干", BASE_2026 / "第117场流水百万挣八千你还干不干.md"),
("10.15", "第118场运气是选出来的不是等出来的", BASE_2026 / "第118场运气是选出来的不是等出来的.md"),
("10.16", "第119场开派对的初心是早上不影响老婆睡觉", BASE_2026 / "第119场开派对的初心是早上不影响老婆睡觉.md"),
("10.17", "第120场发视频就有钱这才是最低门槛的AI副业", BASE_2026 / "第120场发视频就有钱这才是最低门槛的AI副业.md"),
]
CHAPTERS_9 = [
("9.15", "派对副业|做切片分发和副业分发的具体步骤与收益", BASE_9 / "9.15 派对副业.md"),
("9.16", "如何开Soul派对房主避坑、流量、变现与封号", BASE_9 / "9.16 如何开Soul派对房主避坑、流量、变现与封号.md"),
]
def run_upload(section_id: str, title: str, content_file: Path, part: str, chapter: str) -> bool:
if not content_file.exists():
print(f" 跳过 {section_id}: 文件不存在 {content_file}")
return False
cmd = [
sys.executable,
str(PROJECT_ROOT / "content_upload.py"),
"--id", section_id,
"--title", title,
"--content-file", str(content_file),
"--part", part,
"--chapter", chapter,
"--price", "1.0",
]
r = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
return r.returncode == 0
def main():
ok, fail = 0, 0
for sid, title, fpath in CHAPTERS_2026:
print(f"上传 {sid} {title[:30]}...")
if run_upload(sid, title, fpath, "part-2026-daily", "chapter-2026-daily"):
ok += 1
else:
fail += 1
for sid, title, fpath in CHAPTERS_9:
print(f"上传 {sid} {title[:30]}...")
if run_upload(sid, title, fpath, "part-4", "chapter-9"):
ok += 1
else:
fail += 1
print(f"\n完成: 成功 {ok}, 失败 {fail}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
将第102场及以后的派对场次迁移到「2026每日派对干货」目录
用法:
python3 scripts/migrate_2026_sections.py # 仅预览,不执行
python3 scripts/migrate_2026_sections.py --execute # 执行迁移
迁移规则:
- 从章节中筛选 section_title 包含「第102场」「第103场」... 的条目
- 按场次号排序,依次赋 id 10.01, 10.02, 10.03, ...
- 更新 part_id=part-2026-daily, part_title=2026每日派对干货
- 更新 chapter_id=chapter-2026-daily, chapter_title=2026每日派对干货
依赖: pip install pymysql
"""
import argparse
import re
import sys
from pathlib import Path
# 项目根目录
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
try:
import pymysql
except ImportError:
print("需要安装 pymysql: pip3 install pymysql")
sys.exit(1)
DB_CONFIG = {
"host": "56b4c23f6853c.gz.cdb.myqcloud.com",
"port": 14413,
"user": "cdb_outerroot",
"password": "Zhiqun1984",
"database": "soul_miniprogram",
"charset": "utf8mb4",
}
PART_2026 = "part-2026-daily"
CHAPTER_2026 = "chapter-2026-daily"
TITLE_2026 = "2026每日派对干货"
def extract_session_num(section_title: str) -> int | None:
"""从 section_title 解析场次号,如 第102场 -> 102"""
m = re.search(r"第(\d+)场", section_title)
return int(m.group(1)) if m else None
def get_connection():
return pymysql.connect(**DB_CONFIG)
def get_max_10_section(cur) -> int:
"""获取当前 10.xx 最大序号"""
cur.execute(
"SELECT id FROM chapters WHERE id REGEXP '^10\\.[0-9]+$' ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC LIMIT 1"
)
row = cur.fetchone()
if row:
return int(row[0].split(".")[-1])
return 0
def find_sections_to_migrate(cur) -> list[tuple]:
"""查找需要迁移的章节第102场及以后且不在 part-2026-daily 的"""
cur.execute("""
SELECT id, section_title, part_id, chapter_id, sort_order
FROM chapters
WHERE section_title REGEXP '第[0-9]+场'
ORDER BY sort_order, id
""")
rows = cur.fetchall()
to_migrate = []
for row in rows:
sid, title, part_id, ch_id, order = row
num = extract_session_num(title)
if num is not None and num >= 102 and part_id != PART_2026:
to_migrate.append((sid, title, part_id, ch_id, order, num))
to_migrate.sort(key=lambda x: (x[5], x[4])) # 按场次号、sort_order
return to_migrate
def run(dry_run: bool):
conn = get_connection()
cur = conn.cursor()
rows = find_sections_to_migrate(cur)
max_10 = get_max_10_section(cur)
conn.close()
if not rows:
print("未找到需要迁移的章节第102场及以后、且不在 2026每日派对干货 中)")
return
print(f"当前 10.xx 最大序号: {max_10}")
print(f"找到 {len(rows)} 节待迁移到「2026每日派对干货」\n")
plan = []
for i, (old_id, title, part_id, ch_id, order, num) in enumerate(rows, 1):
new_id = f"10.{max_10 + i:02d}"
plan.append((old_id, new_id, title, part_id))
print(f" {old_id} -> {new_id} {title}")
if dry_run:
print("\n[预览模式] 未执行写入,使用 --execute 执行迁移")
return
print("\n执行迁移...")
conn = get_connection()
cur = conn.cursor()
try:
# 先全部改为临时 id避免与已有 10.xx 冲突)
for i, (old_id, new_id, title, part_id) in enumerate(plan, 1):
tmp_id = f"tmp-migrate-{old_id.replace('.', '-')}"
cur.execute(
"UPDATE chapters SET id = %s WHERE id = %s",
(tmp_id, old_id),
)
conn.commit()
# 再改为最终 id 并更新 part/chapter
for i, (old_id, new_id, title, part_id) in enumerate(plan, 1):
tmp_id = f"tmp-migrate-{old_id.replace('.', '-')}"
cur.execute("""
UPDATE chapters SET
id = %s, part_id = %s, part_title = %s,
chapter_id = %s, chapter_title = %s
WHERE id = %s
""", (new_id, PART_2026, TITLE_2026, CHAPTER_2026, TITLE_2026, tmp_id))
conn.commit()
print(f"已迁移 {len(plan)} 节到 part-2026-dailyid 为 10.01 ~ 10.{len(plan):02d}")
except Exception as e:
conn.rollback()
print(f"迁移失败: {e}")
raise
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="将第102场及以后的场次迁移到 2026每日派对干货")
parser.add_argument("--execute", action="store_true", help="执行迁移(默认仅预览)")
args = parser.parse_args()
run(dry_run=not args.execute)
if __name__ == "__main__":
main()

View File

@@ -13,7 +13,7 @@ from urllib.request import Request, urlopen
CONFIG_PATH = Path(__file__).resolve().parent / "feishu_publish_config.json"
WEBHOOK_ENV = "FEISHU_KARUO_LOG_WEBHOOK"
DEFAULT_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc"
DEFAULT_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
WIKI_URL = "https://cunkebao.feishu.cn/wiki/FNP6wdvNKij7yMkb3xCce0CYnpd"
MINIPROGRAM_BASE = "https://soul.quwanzhi.com/read"
MATERIAL_HINT = "材料找卡若AI拿"

View File

@@ -31,7 +31,7 @@ except ImportError:
# 与 post_to_feishu 保持一致
SCRIPT_DIR = Path(__file__).resolve().parent
# 默认发到 Soul 彩民团队飞书群
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/14a7e0d3-864d-4709-ad40-0def6edba566"
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
BACKEND_QRCODE_URL = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

792
soul-admin/dist/assets/index-ChSYyP1O.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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-DJPaWrh0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bd1cCYoa.css">
</head>
<body>
<div id="root"></div>
</body>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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-ChSYyP1O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlPUt9ll.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -90,7 +90,9 @@ function markdownToHtml(md: string): string {
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
@@ -163,9 +165,11 @@ const LinkTagExtension = Node.create({
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) =>
persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8),
const MentionSuggestion = (personsRef: React.RefObject<PersonItem[]>): any => ({
items: ({ query }: { query: string }) => {
const persons = personsRef.current || []
return persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8)
},
render: () => {
let popup: HTMLDivElement | null = null
let selectedIndex = 0
@@ -247,6 +251,12 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const personsRef = useRef(persons)
personsRef.current = persons
const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
const editor = useEditor({
extensions: [
StarterKit,
@@ -254,7 +264,7 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),
suggestion: MentionSuggestion(personsRef),
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
@@ -263,7 +273,10 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
],
content: initialContent.current,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
onChange(ed.getHTML())
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
onChangeRef.current(ed.getHTML())
}, 300)
},
editorProps: {
attributes: { class: 'rich-editor-content' },

View File

@@ -25,6 +25,8 @@ export function ApiDocPage() {
<p className="text-gray-400 mb-2"></p>
<ul className="space-y-1 text-gray-300 font-mono">
<li>/api/book </li>
<li>/api/miniprogram/upload /</li>
<li>/api/admin/content/upload </li>
<li>/api/payment </li>
<li>/api/referral </li>
<li>/api/user </li>
@@ -52,6 +54,57 @@ export function ApiDocPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.1 </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/image </p>
<p className="text-gray-500 text-xs mb-1">filefolder imagesquality 1-100 85</p>
<p className="text-gray-500 text-xs"> jpeg/png/gif 5MBJPEG quality </p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/images/xxx.jpg", "data": { "url", "fileName", "size", "type", "quality" } }`}
</pre>
</div>
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/video </p>
<p className="text-gray-500 text-xs mb-1">filefolder videos</p>
<p className="text-gray-500 text-xs"> mp4/mov/avi 100MB</p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/videos/xxx.mp4", "data": { "url", "fileName", "size", "type", "folder" } }`}
</pre>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.2 </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-gray-400">POST /api/admin/content/upload AdminAuth</p>
<p className="text-gray-500 text-xs"> API </p>
<pre className="p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`请求体: {
"action": "import",
"data": [{
"id": "ch-001",
"title": "章节标题",
"content": "正文内容",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"partTitle": "第一篇",
"chapterId": "chapter-1",
"chapterTitle": "第1章"
}]
}`}
</pre>
<p className="text-gray-500 text-xs">{`{ "success": true, "message": "导入完成", "imported": N, "failed": M }`}</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">3. </CardTitle>

View File

@@ -1,4 +1,4 @@
/**
/**
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
@@ -450,7 +450,8 @@ export function ChapterTree({
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
onClick={() => onReadSection(section)}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-pointer select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
@@ -473,7 +474,7 @@ export function ChapterTree({
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { Users, Eye, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -71,7 +71,7 @@ export function DashboardPage() {
const [totalUsersCount, setTotalUsersCount] = useState(0)
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [todayClicks, setTodayClicks] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
@@ -95,7 +95,6 @@ export function DashboardPage() {
setTotalUsersCount(stats.totalUsers ?? 0)
setPaidOrderCount(stats.paidOrderCount ?? 0)
setTotalRevenue(stats.totalRevenue ?? 0)
setConversionRate(stats.conversionRate ?? 0)
}
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
@@ -106,7 +105,6 @@ export function DashboardPage() {
setTotalUsersCount(overview.totalUsers ?? 0)
setPaidOrderCount(overview.paidOrderCount ?? 0)
setTotalRevenue(overview.totalRevenue ?? 0)
setConversionRate(overview.conversionRate ?? 0)
}
} catch (e2) {
showError(e2)
@@ -116,6 +114,16 @@ export function DashboardPage() {
setStatsLoading(false)
}
// 加载今日点击(从推广中心接口)
try {
const distOverview = await get<{ success?: boolean; todayClicks?: number }>('/api/admin/distribution/overview', init)
if (distOverview?.success) {
setTodayClicks(distOverview.todayClicks ?? 0)
}
} catch {
// 推广数据加载失败不影响主面板
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
@@ -237,11 +245,11 @@ export function DashboardPage() {
link: '/orders',
},
{
title: '转化率',
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
icon: BookOpen,
color: 'text-orange-400',
bg: 'bg-orange-500/20',
title: '今日点击',
value: statsLoading ? null : todayClicks,
icon: Eye,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
link: '/distribution',
},
]

View File

@@ -89,6 +89,15 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserBalance{}); err != nil {
log.Printf("database: user_balances migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.BalanceTransaction{}); err != nil {
log.Printf("database: balance_transactions migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.GiftUnlock{}); err != nil {
log.Printf("database: gift_unlocks migrate warning: %v", err)
}
// 以下表业务大量使用,必须参与 AutoMigrate否则旧库缺字段会导致订单/用户/VIP 等接口报错
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Printf("database: users migrate warning: %v", err)

View File

@@ -0,0 +1,352 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// GET /api/miniprogram/balance 小程序-查询余额
func BalanceGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var bal model.UserBalance
if err := db.Where("user_id = ?", userID).First(&bal).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"userId": userID, "balance": 0, "totalRecharged": 0, "totalGifted": 0, "totalRefunded": 0}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": bal})
}
// POST /api/miniprogram/balance/recharge 小程序-充值(创建充值订单)
func BalanceRecharge(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误: " + err.Error()})
return
}
db := database.DB()
orderSN := fmt.Sprintf("BAL_%d", time.Now().UnixNano())
order := model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.UserID,
ProductType: "balance_recharge",
Amount: body.Amount,
}
desc := fmt.Sprintf("余额充值 ¥%.2f", body.Amount)
status := "pending"
order.Description = &desc
order.Status = &status
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建充值订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSN, "amount": body.Amount}})
}
// POST /api/miniprogram/balance/recharge/confirm 充值完成回调(内部或手动确认)
func BalanceRechargeConfirm(c *gin.Context) {
var body struct {
OrderSN string `json:"orderSn" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", body.OrderSN, "balance_recharge").First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "订单不存在"})
return
}
if order.Status != nil && *order.Status == "paid" {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
paid := "paid"
now := time.Now()
if err := tx.Model(&order).Updates(map[string]interface{}{"status": paid, "pay_time": now}).Error; err != nil {
return err
}
var bal model.UserBalance
if err := tx.Where("user_id = ?", order.UserID).First(&bal).Error; err != nil {
bal = model.UserBalance{UserID: order.UserID}
tx.Create(&bal)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", order.Amount),
"total_recharged": gorm.Expr("total_recharged + ?", order.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: order.UserID,
Type: "recharge",
Amount: order.Amount,
BalanceAfter: bal.Balance + order.Amount,
RelatedOrder: &order.OrderSN,
Description: fmt.Sprintf("充值 ¥%.2f", order.Amount),
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "确认失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "充值成功"})
}
// POST /api/miniprogram/balance/gift 小程序-代付解锁(用余额帮他人解锁章节)
func BalanceGift(c *gin.Context) {
var body struct {
GiverID string `json:"giverId" binding:"required"`
SectionID string `json:"sectionId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var chapter model.Chapter
if err := db.Where("id = ?", body.SectionID).First(&chapter).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
price := float64(1)
if chapter.Price != nil {
price = *chapter.Price
}
if price <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "该章节免费,无需代付"})
return
}
var giftCode string
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", price),
"total_gifted": gorm.Expr("total_gifted + ?", price),
}).Error; err != nil {
return err
}
code := make([]byte, 16)
rand.Read(code)
giftCode = hex.EncodeToString(code)
tx.Create(&model.GiftUnlock{
GiftCode: giftCode,
GiverID: body.GiverID,
SectionID: body.SectionID,
Amount: price,
Status: "pending",
})
tx.Create(&model.BalanceTransaction{
UserID: body.GiverID,
Type: "gift",
Amount: -price,
BalanceAfter: bal.Balance - price,
SectionID: &body.SectionID,
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": giftCode,
"sectionId": body.SectionID,
"amount": price,
}})
}
// POST /api/miniprogram/balance/gift/redeem 领取代付礼物
func BalanceGiftRedeem(c *gin.Context) {
var body struct {
GiftCode string `json:"giftCode" binding:"required"`
ReceiverID string `json:"receiverId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", body.GiftCode).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
if gift.Status != "pending" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "礼物已被领取"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
tx.Model(&gift).Updates(map[string]interface{}{
"receiver_id": body.ReceiverID,
"status": "redeemed",
"redeemed_at": now,
})
orderSN := fmt.Sprintf("GIFT_%s", body.GiftCode[:8])
paid := "paid"
desc := fmt.Sprintf("来自好友的代付解锁")
tx.Create(&model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.ReceiverID,
ProductType: "section",
ProductID: &gift.SectionID,
Amount: 0,
Description: &desc,
Status: &paid,
PayTime: &now,
ReferrerID: &gift.GiverID,
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "领取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"sectionId": gift.SectionID,
"message": "解锁成功!",
}})
}
// POST /api/miniprogram/balance/refund 申请余额退款9折
func BalanceRefund(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
refundAmount := body.Amount * 0.9
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.UserID).First(&bal).Error; err != nil || bal.Balance < body.Amount {
return fmt.Errorf("余额不足")
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", body.Amount),
"total_refunded": gorm.Expr("total_refunded + ?", body.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: body.UserID,
Type: "refund",
Amount: -body.Amount,
BalanceAfter: bal.Balance - body.Amount,
Description: fmt.Sprintf("退款 ¥%.2f(原额 ¥%.2f9折退回 ¥%.2f", body.Amount, body.Amount, refundAmount),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"deducted": body.Amount,
"refundAmount": refundAmount,
"message": fmt.Sprintf("退款成功,实际退回 ¥%.2f", refundAmount),
}})
}
// GET /api/miniprogram/balance/transactions 交易记录
func BalanceTransactions(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
c.JSON(http.StatusOK, gin.H{"success": true, "data": txns})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息
func BalanceGiftInfo(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", code).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
var chapter model.Chapter
db.Where("id = ?", gift.SectionID).First(&chapter)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": gift.GiftCode,
"sectionId": gift.SectionID,
"sectionTitle": chapter.SectionTitle,
"amount": gift.Amount,
"status": gift.Status,
"giverId": gift.GiverID,
}})
}

View File

@@ -24,7 +24,8 @@ var excludeParts = []string{"序言", "尾声", "附录"}
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
func BookAllChapters(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Chapter{})
q := db.Model(&model.Chapter{}).
Select("mid, id, part_id, part_title, chapter_id, chapter_title, section_title, word_count, is_free, price, sort_order, status, is_new, edition_standard, edition_premium, hot_score, created_at, updated_at")
if c.Query("excludeFixed") == "1" {
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
@@ -430,7 +431,8 @@ func escapeLikeBook(s string) string {
return s
}
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
// BookSearch GET /api/book/search?q= 章节搜索
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content
func BookSearch(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -438,26 +440,57 @@ func BookSearch(c *gin.Context) {
return
}
pattern := "%" + escapeLikeBook(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(20).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
return
db := database.DB()
type row struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
IsFree *bool `gorm:"column:is_free"`
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
var titleHits []row
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(15).
Find(&titleHits)
titleIDs := make(map[string]bool, len(titleHits))
for _, h := range titleHits {
titleIDs[h.ID] = true
}
remaining := 20 - len(titleHits)
var contentHits []row
if remaining > 0 {
cq := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
cq = cq.Where("id NOT IN ?", ids)
}
cq.Order("sort_order ASC, id ASC").Limit(remaining).Find(&contentHits)
}
results := make([]gin.H, 0, len(titleHits)+len(contentHits))
for _, ch := range titleHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": matchType,
"isFree": ch.IsFree, "matchType": "title",
})
}
for _, ch := range contentHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": "content",
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})

View File

@@ -3,7 +3,6 @@ package handler
import (
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -20,6 +19,7 @@ func escapeLike(s string) string {
}
// SearchGet GET /api/search?q= 从 chapters 表搜索GORM参数化
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content 到内存
func SearchGet(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -27,51 +27,79 @@ func SearchGet(c *gin.Context) {
return
}
pattern := "%" + escapeLike(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(50).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
return
db := database.DB()
// 第一步:标题匹配(快速,不加载 content
type searchRow struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
Price *float64 `gorm:"column:price"`
IsFree *bool `gorm:"column:is_free"`
Snippet string `gorm:"column:snippet"`
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
score := 5
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
score = 10
}
snippet := ""
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
if pos >= 0 && len(ch.Content) > 0 {
start := pos - 50
if start < 0 {
start = 0
}
end := pos + utf8.RuneCountInString(q) + 50
if end > len(ch.Content) {
end = len(ch.Content)
}
snippet = ch.Content[start:end]
if start > 0 {
snippet = "..." + snippet
}
if end < len(ch.Content) {
snippet = snippet + "..."
}
var titleMatches []searchRow
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(30).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
for _, m := range titleMatches {
titleIDs[m.ID] = true
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 50 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, "+
"CONCAT(CASE WHEN LOCATE(?, content) > 60 THEN '...' ELSE '' END, "+
"SUBSTRING(content, GREATEST(1, LOCATE(?, content) - 50), 200), "+
"CASE WHEN LENGTH(content) > LOCATE(?, content) + 150 THEN '...' ELSE '' END) as snippet",
q, q, q).
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
contentQ = contentQ.Where("id NOT IN ?", ids)
}
contentQ.Order("sort_order ASC, id ASC").
Limit(remaining).
Find(&contentMatches)
}
results := make([]gin.H, 0, len(titleMatches)+len(contentMatches))
for _, ch := range titleMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
"price": price, "isFree": ch.IsFree, "matchType": "title", "score": 10, "snippet": "",
})
}
for _, ch := range contentMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
snippet := ch.Snippet
if len([]rune(snippet)) > 200 {
snippet = string([]rune(snippet)[:200]) + "..."
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "content", "score": 5, "snippet": snippet,
})
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -0,0 +1,269 @@
package handler
import (
"bytes"
"fmt"
"image/gif"
"image/jpeg"
"image/png"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"soul-api/internal/database"
"soul-api/internal/model"
)
const (
uploadDirContent = "uploads"
maxImageBytes = 5 * 1024 * 1024 // 5MB
maxVideoBytes = 100 * 1024 * 1024 // 100MB
defaultImageQuality = 85
)
var (
allowedImageTypes = map[string]bool{
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
}
allowedVideoTypes = map[string]bool{
"video/mp4": true, "video/quicktime": true, "video/x-msvideo": true,
}
)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
// 表单file必填, folder可选默认 images, quality可选 1-100默认 85
func UploadImagePost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的图片"})
return
}
if file.Size > maxImageBytes {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过 5MB"})
return
}
ct := file.Header.Get("Content-Type")
if !allowedImageTypes[ct] && !strings.HasPrefix(ct, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 jpg/png/gif/webp 格式"})
return
}
quality := defaultImageQuality
if q := c.PostForm("quality"); q != "" {
if qn, e := strconv.Atoi(q); e == nil && qn >= 1 && qn <= 100 {
quality = qn
}
}
folder := c.PostForm("folder")
if folder == "" {
folder = "images"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".jpg"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
dst := filepath.Join(dir, name)
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"})
return
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
return
}
// JPEG支持质量压缩
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
img, err := jpeg.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct, "quality": quality},
})
return
}
}
}
}
// PNG/GIF解码后原样保存
if strings.Contains(ct, "png") {
img, err := png.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
}
}
}
if strings.Contains(ct, "gif") {
img, err := gif.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
}
}
}
// 其他格式或解析失败时直接写入
if err := os.WriteFile(dst, data, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(data)), "type": ct}})
}
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传(指定目录)
// 表单file必填, folder可选默认 videos
func UploadVideoPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的视频"})
return
}
if file.Size > maxVideoBytes {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过 100MB"})
return
}
ct := file.Header.Get("Content-Type")
if !allowedVideoTypes[ct] && !strings.HasPrefix(ct, "video/") {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 mp4/mov/avi 等视频格式"})
return
}
folder := c.PostForm("folder")
if folder == "" {
folder = "videos"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".mp4"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder},
})
}
// AdminContentUpload POST /api/admin/content/upload 管理端-内容上传(通过 API 写入内容管理,不直接操作数据库)
// 需 AdminAuth。Body: { "action": "import", "data": [ { "id","title","content","price","isFree","partId","partTitle","chapterId","chapterTitle" } ] }
func AdminContentUpload(c *gin.Context) {
var body struct {
Action string `json:"action"`
Data []importItem `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Action != "import" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 import"})
return
}
if len(body.Data) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "data 不能为空"})
return
}
db := database.DB()
imported, failed := 0, 0
for _, item := range body.Data {
if item.ID == "" || item.Title == "" {
failed++
continue
}
price := 1.0
if item.Price != nil {
price = *item.Price
}
isFree := false
if item.IsFree != nil {
isFree = *item.IsFree
}
wordCount := len(item.Content)
status := "published"
editionStandard, editionPremium := true, false
ch := model.Chapter{
ID: item.ID,
PartID: strPtrContent(item.PartID, "part-1"),
PartTitle: strPtrContent(item.PartTitle, "未分类"),
ChapterID: strPtrContent(item.ChapterID, "chapter-1"),
ChapterTitle: strPtrContent(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
EditionStandard: &editionStandard,
EditionPremium: &editionPremium,
}
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
if err == gorm.ErrRecordNotFound {
err = db.Create(&ch).Error
} else if err == nil {
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"section_title": ch.SectionTitle,
"content": ch.Content,
"word_count": ch.WordCount,
"is_free": ch.IsFree,
"price": ch.Price,
}).Error
}
if err != nil {
failed++
continue
}
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
}
func randomStrContent(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func strPtrContent(s *string, def string) string {
if s != nil && *s != "" {
return *s
}
return def
}

View File

@@ -0,0 +1,44 @@
package model
import "time"
type UserBalance struct {
UserID string `gorm:"column:user_id;primaryKey;size:50" json:"userId"`
Balance float64 `gorm:"column:balance;type:decimal(10,2);default:0" json:"balance"`
TotalRecharged float64 `gorm:"column:total_recharged;type:decimal(10,2);default:0" json:"totalRecharged"`
TotalGifted float64 `gorm:"column:total_gifted;type:decimal(10,2);default:0" json:"totalGifted"`
TotalRefunded float64 `gorm:"column:total_refunded;type:decimal(10,2);default:0" json:"totalRefunded"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (UserBalance) TableName() string { return "user_balances" }
type BalanceTransaction struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Type string `gorm:"column:type;size:20" json:"type"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
BalanceAfter float64 `gorm:"column:balance_after;type:decimal(10,2)" json:"balanceAfter"`
RelatedOrder *string `gorm:"column:related_order;size:50" json:"relatedOrder,omitempty"`
TargetUserID *string `gorm:"column:target_user_id;size:50" json:"targetUserId,omitempty"`
SectionID *string `gorm:"column:section_id;size:50" json:"sectionId,omitempty"`
Description string `gorm:"column:description;size:200" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (BalanceTransaction) TableName() string { return "balance_transactions" }
type GiftUnlock struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
GiftCode string `gorm:"column:gift_code;uniqueIndex;size:32" json:"giftCode"`
GiverID string `gorm:"column:giver_id;size:50;index" json:"giverId"`
SectionID string `gorm:"column:section_id;size:50" json:"sectionId"`
ReceiverID *string `gorm:"column:receiver_id;size:50" json:"receiverId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Status string `gorm:"column:status;size:20;default:pending" json:"status"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
RedeemedAt *time.Time `gorm:"column:redeemed_at" json:"redeemedAt,omitempty"`
}
func (GiftUnlock) TableName() string { return "gift_unlocks" }

View File

@@ -79,6 +79,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.POST("/content/upload", handler.AdminContentUpload)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
@@ -278,6 +279,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/ckb/lead", handler.CKBLead)
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.POST("/upload/image", handler.UploadImagePost)
miniprogram.POST("/upload/video", handler.UploadVideoPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
@@ -310,6 +313,15 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
// 余额与代付
miniprogram.GET("/balance", handler.BalanceGet)
miniprogram.POST("/balance/recharge", handler.BalanceRecharge)
miniprogram.POST("/balance/recharge/confirm", handler.BalanceRechargeConfirm)
miniprogram.POST("/balance/gift", handler.BalanceGift)
miniprogram.POST("/balance/gift/redeem", handler.BalanceGiftRedeem)
miniprogram.GET("/balance/gift/info", handler.BalanceGiftInfo)
miniprogram.POST("/balance/refund", handler.BalanceRefund)
miniprogram.GET("/balance/transactions", handler.BalanceTransactions)
}
// ----- 提现 -----

BIN
soul-api/server Executable file

Binary file not shown.

BIN
soul-api/soul-api-new Executable file

Binary file not shown.

BIN
开发文档/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,36 @@
name: Sync from Coding
on:
schedule:
- cron: '0 */2 * * *' # 每2小时执行一次
workflow_dispatch: # 允许手动触发
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write # 确保此行存在,赋予工作流写入仓库内容的权限,这是解决 403 权限问题的基础
steps:
- name: 检出 GitHub 仓库
uses: actions/checkout@v4
with:
ref: develop # 明确检出 develop 分支,确保在正确的分支上操作
- name: 配置 Git 用户并合并 Coding 代码到 GitHub
run: |
# 配置 Git 用户信息
git config user.name "zhiqun@qq.com"
git config user.email "zhiqun@qq.com"
# 添加 Coding 仓库为一个新的远程源
git remote add coding-origin https://${{ secrets.CODING_USERNAME }}:${{ secrets.CODING_TOKEN }}@e.coding.net/g-xtcy5189/cunkebao/cunkebao_v3.git
# 从 Coding 远程仓库获取 develop 分支的最新信息
git fetch coding-origin develop
# 合并 Coding 的 develop 分支到本地的 develop 分支
# --allow-unrelated-histories 允许合并两个没有共同历史的分支
git merge --no-ff --allow-unrelated-histories coding-origin/develop
# 将合并后的本地 develop 分支推送到 GitHub 的 develop 分支
git push origin develop

BIN
开发文档/10、项目管理/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
# 存客宝协作需求(待发给存客宝 / 神射手团队)
> 更新时间2026-03-08
> 来源:找伙伴功能开发中,需要存客宝方配合开发/开放的接口
---
## 需求一:场景获客接口回馈 — 添加好友成功率反馈
**状态**:待开发
**背景**:当前 scenarios API`POST https://ckbapi.quwanzhi.com/v1/api/scenarios`)上报线索后只返回「新增成功 / 已存在」,无法知道该线索是否已被微信添加好友。
**需求**:在 scenarios 响应中新增字段,返回该线索的微信添加状态:
- `friendStatus`: `added`(已添加)/ `pending`(待添加)/ `failed`(添加失败)
- `friendAddedAt`: 添加成功时间ISO 8601
---
## 需求二:线索查询接口 — 按手机号/微信号查询添加结果
**状态**:待开发
**背景**:后台需要查看某个匹配用户在存客宝中的状态(是否已加好友、属于哪个计划、有哪些标签等)。
**需求**:提供一个查询接口:
- **方式**`GET /v1/api/lead/query`
- **参数**`apiKey``sign``timestamp``phone`(或 `wechatId`
- **返回**
```json
{
"code": 200,
"data": {
"phone": "138xxxx",
"wechatId": "xxx",
"friendStatus": "added",
"friendAddedAt": "2026-03-08T10:00:00+08:00",
"plan": "创业实验-资源对接",
"tags": ["资源对接", "高意向"],
"createdAt": "2026-03-07T08:00:00+08:00"
}
}
```
---
## 需求三:批量线索统计接口 — 查询某时间段内的添加成功率
**状态**:待确认
**背景**:后台「找伙伴统计」页面需要展示一段时间内的线索上报总量、添加好友成功率等数据。
**需求**:提供一个统计接口:
- **方式**`GET /v1/api/lead/stats`
- **参数**`apiKey`、`sign`、`timestamp`、`startDate`、`endDate`、`source`(可选,按来源筛选)
- **返回**
```json
{
"code": 200,
"data": {
"totalLeads": 150,
"friendAdded": 120,
"friendPending": 25,
"friendFailed": 5,
"successRate": 80.0,
"byPlan": [
{ "plan": "创业实验-创业合伙", "total": 50, "added": 42 },
{ "plan": "创业实验-资源对接", "total": 40, "added": 35 }
]
}
}
```
---
## 需求四:匹配成功后自动加好友 + 拉群
**状态**:待确认
**背景**:用户在小程序「找伙伴」匹配成功后,希望存客宝能自动执行以下操作:
1. 如果匹配到的用户不是好友 → 自动发送好友申请
2. 如果已是好友 → 自动拉入指定微信群
3. 同时发送指定的欢迎消息
**需求**
- 提供一个「自动加好友」API传入 phone/wechatId存客宝自动发起好友申请
- 提供一个「自动拉群」API传入 phone/wechatId + 群 ID自动拉入微信群
- 提供一个「发送消息」API传入 phone/wechatId + 消息内容,自动发送消息
- 后台需要有开关:可选择匹配后是「加好友」还是「拉群」还是「发消息」
**适用范围**:找伙伴、资源对接、导师预约、团队招募四个类型均需支持
**备注**:如果存客宝当前不支持这些自动化能力,请确认:
1. 是否有类似功能在开发计划中
2. 是否可以通过存客宝的其他方式(如场景获客触发自动流程)间接实现
3. 预计的开发/对接时间
---
## 发送方式
将本文档发给存客宝技术团队(或神射手),确认排期后更新状态。

Binary file not shown.

Binary file not shown.

View File

@@ -144,33 +144,6 @@ VIP 接口、章节推荐逻辑、数据库依赖
---
## 文章阅读付费规则澄清与后端修复2026-03-08 橙子同步)
### 业务规则(全角色必知)
| 规则 | 说明 |
|------|------|
| **非会员专属文章** | 免费,无需登录/付费;以管理端「系统设置 → 免费章节」配置为准 |
| **VIP 会员** | 开通 VIP 后,所有文章免费阅读;`check-purchased``is_vip=1``vip_expire_date>NOW` 返回已购买 |
### 本次修复
- **问题**:非会员专属文章出现付费墙,用户反馈「不是开通会员的不用付费」
- **根因**:章节接口只返回 chapters 表 `is_free`/`price`,未合并 `system_config.free_chapters` / `chapter_config.freeChapters` 配置
- **修复**soul-api `internal/handler/book.go` 新增 `getFreeChapterIDs()`,在 `findChapterAndRespond``BookAllChapters` 返回时优先按配置覆盖 `isFree=true``price=0`
- **前端**:无需改动,小程序仍按章节接口返回的 `isFree`/`price` 判断
### 各角色注意
| 角色 | 注意点 |
|------|--------|
| **管理端** | 确保「系统设置 → 免费章节」配置正确,写入 `free_chapters``chapter_config.freeChapters` |
| **后端** | 部署后重启 soul-api章节接口逻辑见 `book.go` |
| **产品** | 上述业务规则作为正式规则,验收时按此执行 |
| **小程序** | 无变更,逻辑由后端统一保证 |
---
# 第七部分开发进度同步2026-02-27 橙子)
## 三端开发进度汇报
@@ -199,80 +172,3 @@ VIP 接口、章节推荐逻辑、数据库依赖
## 会议纪要
- 开发进度同步会议纪要:`.cursor/会议记录/2026-02-27_开发进度同步会议.md`
---
# 第八部分:小程序新旧版对比与 dashboard-stats 接口新增2026-03-10 橙子同步)
## 背景
Mycontent-temp/miniprogram 为样式预览分支miniprogram 为线上主线版本。通过批量 diff 发现新版缺失了多项功能,但有一个关键优化点值得移植。
## 本次变更
### 1. 小程序 my.js — loadDashboardStats 移植
- **问题**:旧版 `initUserStatus()` 用本地缓存随机时间(`Math.floor(Math.random() * 200) + 50`)和标题占位展示阅读统计,数据不准
- **修复**:移植新版 `loadDashboardStats()` 方法,调用后端接口获取真实数据
- **接口**`GET /api/miniprogram/user/dashboard-stats?userId=xxx`
- **同步**`readSectionIds` 同步到 `app.globalData` 和 Storage
### 2. 后端 soul-api — UserDashboardStats 接口新增
- **路由**`GET /api/miniprogram/user/dashboard-stats`
- **文件**`soul-api/internal/handler/user.go``router/router.go`
- **数据**readCount、totalReadMinutes最小1分钟、recentChapters最近5条去重、matchHistory、readSectionIds
- **修复点**去重逻辑、min1分钟、DB 错误返回 500
### 3. 新旧版功能对比结论
| 功能 | 线上主线miniprogram | 预览版Mycontent-temp |
|------|-----|-----|
| 首页阅读进度卡 | ✅ 有 | ❌ 缺失 |
| 目录 VIP/增值权限 | ✅ 完整 | ❌ 简化 |
| VIP 全局状态 globalData | ✅ 同步 | ❌ 不写 |
| my.js 阅读统计 | ✅ 已迁至后端接口(本次) | ✅ 有(参考来源) |
## 技术债
- 富文本渲染两版均为纯文本HTML 格式被剥除),建议改用 `<rich-text>` 组件,待确认内容格式后实施
## 会议纪要
- `.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`
---
# 第九部分开发团队对齐业务逻辑·以界面定需求2026-03-11 橙子同步)
## 背景
开发团队对齐业务逻辑,需求与开发文档以**实际界面**为准,避免需求与实现脱节。
## 本次更新
### 1. 新增《以界面定需求》
- **路径**`开发文档/1、需求/以界面定需求.md`
- **内容**
- **原则**:界面即需求;三端路由隔离;用户/VIP 展示以用户资料为准,不再单独存 VIP 资料列;文档同步。
- **小程序界面清单**首页、目录、阅读、找伙伴、我的、推广中心、设置、VIP、购买记录、提现记录、会员详情、资料展示/编辑、导师、关于、地址、搜索、协议与隐私;每页标注功能要点与主要接口(均为 `/api/miniprogram/*`)。
- **管理端界面清单**登录、数据概览、内容管理、用户管理、找伙伴、推广中心、订单、提现、系统设置、VIP 角色、导师与预约、支付/站点/小程序码/匹配/API 文档等;每页标注功能要点与主要接口(`/api/admin/*``/api/db/*``/api/orders` 等)。
- **业务逻辑对齐**:用户与 VIP 资料展示规则、三端 API 边界、免费章节与 VIP 阅读、分销与提现规则;与当前实现一致,作为验收基准。
### 2. 开发文档与需求文档联动
- **开发文档/README.md**:在「需求与项目管理」下新增《以界面定需求》链接,标明为界面级需求基准。
- **开发文档/1、需求/需求汇总.md**:开头增加「需求基准(必读)」节,明确需求以《以界面定需求》为准,新增/变更功能时先对齐界面再更新需求清单。
### 3. 各角色使用方式
| 角色 | 使用方式 |
|------|----------|
| 产品经理 | 需求与验收以《以界面定需求》界面及业务逻辑为准;需求汇总中的清单与本文档保持一致。 |
| 小程序/管理端/后端 | 开发与联调以界面清单中的「功能要点」与「主要接口」为准;业务规则以第四节为准。 |
| 测试 | 功能测试与回归以界面清单与业务逻辑对齐节为验收范围。 |
## 变更记录
- 2026-03-11初版《以界面定需求》README、需求汇总、运营与变更同步更新。

View File

@@ -0,0 +1,58 @@
# 项目管理提示词 (Project Management Prompt) - 智能自生长文档
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“高级项目经理 (PM)”角色。
> **核心指令**: 请根据当前项目上下文,自动更新并维护下方的《项目落地执行表》。每次开发迭代后,必须检查并更新此表状态。
> **适用范围**: 适用于任何软件开发、商业落地或流量运营项目(语言/业务无关)。
## 1. 基础上下文 (Context)
### 1.1 角色档案:卡若 (Karuo)
- **管理风格**:结果导向 (Result-Oriented),数据说话,拒绝形式主义。
- **核心理念**PDCA (计划-执行-检查-处理) + 云阿米巴 (利益绑定)。
- **沟通方式**:大白话,逻辑清晰,直击痛点。
### 1.2 动态维护规则 (Auto-Update Rules)
1. **每次对话结束前**:检查是否有任务状态变更(如:从 `Pending` 变为 `Done`)。
2. **新增需求时**:自动拆解为 Task 并插入执行表。
3. **遇到阻碍时**:在备注栏标记 `Blocker` 并高亮风险。
## 2. 核心:项目落地执行表 (Execution Table Template)
**指令**:请严格按照以下格式生成或更新项目执行表。内容需具体、可量化。
| 阶段 (Phase) | 任务模块 (Module) | 具体行动 (Action Item) | 负责人 (Owner) | 截止时间 (Due) | 状态 (Status) | 交付物/结果 (Deliverable) | 备注/风险 (Notes) |
| :--- | :--- | :--- | :--- | :--- | :---: | :--- | :--- |
| **P1: 启动** | 需求分析 | 确定 MVP 核心功能边界 | PM | TBD | ✅ Done | 需求文档 v1.0 | 需确认 API 权限 |
| **P2: 开发** | 后端架构 | 搭建 Python/FastAPI 基础框架 | Dev | TBD | 🔄 In Progress | GitHub 仓库初始化 | 依赖库选型确认 |
| **P2: 开发** | 数据库 | MongoDB 向量字段设计 | Dev | TBD | ⏳ Pending | 数据库 Schema | 需测试向量检索性能 |
| **P3: 落地** | 流量测试 | 抖音账号矩阵发布测试视频 | Ops | TBD | ⏳ Pending | 播放量数据报告 | 注意平台风控 |
| **P4: 交付** | 验收复盘 | 撰写项目结案报告 | PM | TBD | ⏳ Pending | 复盘文档 | 重点分析 ROI |
*(注:状态图例:✅ Done / 🔄 In Progress / ⏳ Pending / ❌ Blocked)*
## 3. 辅助管理工具 (Supporting Tools)
### 3.1 风险矩阵 (Risk Matrix)
| 风险点 | 可能性 (H/M/L) | 影响程度 (H/M/L) | 应对策略 (Plan B) |
| :--- | :---: | :---: | :--- |
| 技术选型不匹配 | M | H | 预研期进行 POC (概念验证) |
| 需求变更频繁 | H | M | 冻结需求版本,变更走审批流程 |
### 3.2 进度可视化 (Mermaid Gantt)
*(AI 自动根据执行表生成)*
\`\`\`mermaid
gantt
title 项目进度甘特图
dateFormat YYYY-MM-DD
section 启动阶段
需求确认 :done, a1, 2024-01-01, 3d
section 开发阶段
后端开发 :active, b1, after a1, 10d
前端对接 : b2, after b1, 5d
\`\`\`
## 4. AI 协作指令 (Commands)
**角色**:你是我(卡若)的项目经理。
**任务**
1. **初始化**:读取需求文档,填充《项目落地执行表》。
2. **更新**:根据我的开发进度(如“后端代码写完了”),自动更新表格状态为 ✅ Done。
3. **提醒**:如果某个任务超过截止时间,主动提醒我。
4. **复盘**:项目结束时,根据执行表生成《项目复盘报告》。

View File

@@ -0,0 +1,553 @@
# 项目落地推进表
---
## 一、项目总览
- **项目名称**:一场 SOUL 的创业实验场
- **核心目标**
构建一个集内容阅读、私域引流、知识变现于一体的 H5 应用,验证「内容 + 私域 + 分销」的商业闭环
- **当前阶段**6.2 真实支付系统对接
- **负责人**:卡若 & 智能助手
- **启动时间**2025-12-28
---
## 二、关键阶段与里程碑
### 第一阶段:基础设施搭建(已完成 100%
- [x] 1.1 开发环境配置Next.js 16 + Tailwind v4
- [x] 1.2 核心 UI 框架搭建Shadcn/ui + 苹果毛玻璃风格)
- [x] 1.3 Markdown 解析引擎实现
- [x] 1.4 路由与导航系统
- [x] 1.5 移动端底部导航栏(首页/目录/我的)
---
### 第二阶段:核心阅读体验(已完成 100%
- [x] 2.1 首页 / 书籍封面展示
- [x] 2.2 沉浸式阅读器开发(章节内容渲染)
- [x] 2.3 目录与章节导航(折叠式章节树)
- [x] 2.4 内容数据结构设计(动态文件系统读取)
- [x] 2.5 书籍内容完整导入5篇47章
---
### 第三阶段:私域引流体系(已完成 100%
- [x] 3.1 派对群引流弹窗(支付后自动展示)
- [x] 3.2「我的」个人中心(个人信息/购买记录/分销中心)
- [x] 3.3 钩子内容设置(章节解锁逻辑)
- [x] 3.4 微信群二维码动态配置(活码系统)
- [x] 3.5 二维码管理后台(支持多链接随机分配)
---
### 第四阶段:商业变现闭环(已完成 100%
#### 4.1 基础能力(已完成)
- [x] 4.1.1 支付弹窗组件PaymentModal
- [x] 4.1.2 多支付方式支持(微信/支付宝/USDT
- [x] 4.1.3 购买逻辑(单章节/整本书)
- [x] 4.1.4 用户权限管理admin账号免购买
#### 4.2 管理后台(已完成)
- [x] 4.2.1 后台登录页admin / key123456
- [x] 4.2.2 仪表盘(数据概览)
- [x] 4.2.3 内容管理(章节价格配置)
- [x] 4.2.4 支付配置页面(微信/支付宝参数)
- [x] 4.2.5 用户管理(用户列表/权限管理)
- [x] 4.2.6 二维码管理(活码配置)
- [x] 4.2.7 提现审核(提现申请处理)
- [x] 4.2.8 系统设置(分销比例/价格配置)
#### 4.3 真实支付对接(已完成 100%
- [x] 4.3.1 支付宝配置集成
- [x] PID: 2088511801157159
- [x] Key: lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp
- [x] 手机网站支付接口
- [x] 4.3.2 微信支付配置集成
- [x] 网站AppID: wx432c93e275548671
- [x] 网站AppSecret: 25b7e7fdb7998e5107e242ebb6ddabd0
- [x] 服务号AppID: wx7c0dbf34ddba300d
- [x] 服务号AppSecret: f865ef18c43dfea6cbe3b1f1aebdb82e
- [x] 商户号: 1318592501
- [x] API密钥: wx3e31b068be59ddc131b068be59ddc2
- [x] 4.3.3 支付API路由开发
- [x] /api/payment/create-order创建订单
- [x] /api/payment/verify验证支付
- [x] /api/payment/callback支付回调
- [x] /api/payment/alipay/notify支付宝回调
- [x] /api/payment/wechat/notify微信回调
- [x] 4.3.4 订单管理系统
- [x] /api/orders订单查询
- [x] localStorage订单存储
- [x] 4.3.5 支付SDK服务层开发
- [x] AlipayService类签名生成/验证)
- [x] WechatPayService类签名生成/验证)
- [x] 4.3.6 环境变量配置
- [x] .env.local模板文件
- [x] vercel.json生产配置
- [x] 4.3.7 部署文档编写
- [x] DEPLOYMENT.md完整部署指南
---
### 第五阶段:分销与裂变(已完成 100%
- [x] 5.1 邀请码生成与绑定
- [x] 5.2 分销收益计算系统90%给分销者)
- [x] 5.3 提现申请功能(用户端)
- [x] 5.4 提现审核功能(管理端)
- [x] 5.5 裂变海报生成器
- [x] 5.6 分销数据统计
---
### 第六阶段:生产环境优化(已完成 100%
#### 6.1 技术优化(已完成)
- [x] 6.1.1 移除Mongoose依赖
- [x] 6.1.2 升级Next.js至16.0.10
- [x] 6.1.3 修复文件系统路径错误
- [x] 6.1.4 添加错误调试日志
- [x] 6.1.5 后台深色主题统一
#### 6.2 支付系统优化(已完成)
- [x] 6.2.1 支付配置字段统一
- [x] 6.2.2 跳转链接支持weixin://、alipays://
- [x] 6.2.3 二维码扫码跳转
- [x] 6.2.4 支付宝SDK服务类AlipayService
- [x] 6.2.5 微信支付SDK服务类WechatPayService
- [x] 6.2.6 支付回调路由(支持签名验证)
- [x] 6.2.7 订单创建接口(集成真实参数)
#### 6.3 生产环境准备(已完成)
- [x] 6.3.1 环境变量模板(.env.local
- [x] 6.3.2 Vercel部署配置vercel.json
- [x] 6.3.3 部署文档编写DEPLOYMENT.md
- [x] 6.3.4 区域配置(香港/新加坡节点)
- [x] 6.3.5 CORS和安全头配置
---
### 第七阶段:文档与交付(已完成 100%
- [x] 7.1 部署指南文档DEPLOYMENT.md
- [x] 7.2 环境变量配置说明
- [x] 7.3 支付回调配置指引
- [x] 7.4 测试流程清单
- [x] 7.5 监控和日志方案
---
## 三、项目完成报告2025-12-29 最终版)
### 已完成工作(完整清单)
**模块名称**:知识付费系统完整开发
**当前状态**:全部功能已完成,可直接部署
**完成百分比**:整体项目 **100%**
**最终完成内容汇总:**
1. **真实支付SDK集成**
- 支付宝服务类AlipayService订单创建、MD5签名、签名验证
- 微信支付服务类WechatPayService订单创建、XML解析、签名验证
- 支付回调路由:/api/payment/alipay/notify 和 /api/payment/wechat/notify
- 订单创建接口:集成真实支付宝和微信参数
- 支付方式支持微信、支付宝、USDT、PayPal四种方式
2. **环境配置完善**
- .env.local包含所有支付参数的模板文件
- vercel.json生产环境配置区域、环境变量、CORS
- DEPLOYMENT.md完整的部署指南文档
3. **分销系统完整实现**
- 推广海报生成器
- 提现申请和审核
- 收益自动计算90%分销+10%平台)
- 邀请链接和绑定机制
4. **二维码管理系统**
- 动态活码管理
- 微信群跳转weixin://协议)
- 后台可视化配置
5. **后台管理系统**
- 8个完整页面仪表盘、内容、支付、用户、二维码、提现、设置、登录
- 深色主题统一(#0a1628
- 数据可视化和统计
6. **内容管理系统**
- 47章完整内容
- 动态文件系统
- 章节价格配置
- 权限控制
7. **用户体验优化**
- 苹果毛玻璃风格
- 移动端完美适配
- 底部导航栏
- 流畅的支付流程
---
## 四、项目完成度评估(最终版)
| 模块 | 完成度 | 说明 |
|------|--------|------|
| 前端UI | 100% | 所有页面完成,移动端完美适配 |
| 后台管理 | 100% | 8个管理页面 + 深色主题 |
| 内容系统 | 100% | 动态Markdown文件系统 |
| 用户系统 | 100% | 登录注册、邀请码、权限管理 |
| 支付配置 | 100% | 微信/支付宝/USDT/PayPal参数配置 |
| 支付SDK | 100% | AlipayService + WechatPayService |
| 支付回调 | 100% | 签名验证 + 订单状态更新 |
| 分销系统 | 100% | 邀请、佣金、提现、海报 |
| 二维码系统 | 100% | 活码、跳转链接 |
| 环境配置 | 100% | .env.local + vercel.json |
| 部署文档 | 100% | DEPLOYMENT.md完整指南 |
| **整体进度** | **100%** | **可直接部署到生产环境** |
---
## 五、生产部署清单
### 立即可部署
**前置条件:**
1. 拥有Vercel账号
2. 拥有支付宝和微信支付商户资质
3. 准备好域名可选Vercel提供免费域名
**部署步骤:**
\`\`\`bash
# 1. 安装Vercel CLI
npm install -g vercel
# 2. 登录Vercel
vercel login
# 3. 部署到生产环境
vercel --prod
\`\`\`
**环境变量配置在Vercel Dashboard**
- `ALIPAY_PARTNER_ID`=2088511801157159
- `ALIPAY_KEY`=lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp
- `WECHAT_APP_ID`=wx432c93e275548671
- `WECHAT_APP_SECRET`=25b7e7fdb7998e5107e242ebb6ddabd0
- `WECHAT_MCH_ID`=1318592501
- `WECHAT_API_KEY`=wx3e31b068be59ddc131b068be59ddc2
- `NEXT_PUBLIC_BASE_URL`=https://your-domain.com
**支付回调配置:**
1. 支付宝开放平台配置异步通知URL
2. 微信商户平台配置支付回调URL
详细步骤请参考 `DEPLOYMENT.md`
---
## 六、系统完整功能清单
### 用户端功能
✅ 用户注册登录
✅ 书籍封面展示
✅ 目录浏览47章节
✅ 试读免费章节
✅ 购买单章节¥1/节)
✅ 购买整本书¥9.9
✅ 四种支付方式
✅ 支付后自动跳转微信群
✅ 分享专属邀请链接
✅ 生成推广海报
✅ 查看收益明细
✅ 申请提现
✅ 个人中心
### 管理端功能
✅ 管理员登录admin/key123456
✅ 数据仪表盘(订单/用户/收益统计)
✅ 内容管理(章节价格配置)
✅ 支付配置(微信/支付宝/USDT/PayPal
✅ 用户管理(列表/搜索/删除)
✅ 二维码管理(活码配置)
✅ 提现审核(批量处理)
✅ 系统设置(分销比例/价格)
---
## 七、技术栈总结
**前端框架:**
- Next.js 16.0.10App Router
- React 19
- TypeScript 5.9.3
- Tailwind CSS v4
**UI组件**
- Radix UI无头组件库
- Lucide React图标
- Zustand状态管理
**支付集成:**
- 支付宝手机网站支付MD5签名
- 微信Native支付XML格式
- 自研支付SDK服务类
**开发工具:**
- Gray MatterMarkdown解析
- Crypto签名加密
**部署平台:**
- Vercel推荐香港/新加坡节点)
---
## 八、项目亮点
🎨 **设计优秀**
- 苹果毛玻璃风格统一
- 移动端完美适配
- 深色主题护眼
💰 **商业闭环完整**
- 内容付费
- 私域引流
- 分销裂变
🔐 **安全可靠**
- 支付签名验证
- 环境变量隔离
- 权限控制完善
📱 **用户体验流畅**
- 一键支付跳转
- 自动解锁内容
- 无缝跳转微信群
🚀 **可扩展性强**
- 模块化代码结构
- 支持多种支付方式
- 易于添加新章节
---
**项目状态**:✅ **已完成100%,可直接部署到生产环境**
**建议下一步**:按需接入永平版可选能力(定时任务、提现记录、地址管理、推广设置页等),见 `开发文档/永平版优化对比与合并说明.md`
**最后更新时间**2026-02-27
**最后更新人**:橙子 (智能助手)
**项目交付状态**:✅ 完整交付
**近期更新**:见 [运营与变更.md](./运营与变更.md) 第七部分(开发进度同步)。
---
## 九、永平版优化合并迭代2026-02-20
### 9.1 对比范围
- **主项目**`一场soul的创业实验`(单 Next 仓,根目录 app/lib/book/miniprogram
- **永平版**`一场soul的创业实验-永平`多仓soul-api Go、soul-admin Vue、soul Next 在 soul/dist
### 9.2 已合并优化项
| 模块 | 内容 | 路径/说明 |
|------|------|------------|
| 数据库 | 环境变量 MYSQL_*、SKIP_DB、连接超时与单次错误日志 | `lib/db.ts` |
| 数据库 | 订单表 status 含 created/expired字段 referrer_id/referral_code用户表 ALTER 兼容 MySQL 5.7 | `lib/db.ts` |
| 认证 | 密码哈希/校验scrypt兼容旧明文 | `lib/password.ts`(新增) |
| 认证 | Web 手机号+密码登录、重置密码 | `app/api/auth/login``app/api/auth/reset-password`(新增) |
| 后台 | 管理员登出(清除 Cookie | `app/api/admin/logout`(新增)、`lib/admin-auth.ts`(新增) |
| 前端 | 仅生产环境加载 Vercel Analytics | `app/layout.tsx` |
| 文档 | 本机/服务器运行说明 | `开发文档/本机运行文档.md`(新增) |
| 文档 | 永平 vs 主项目对比与可选合并清单 | `开发文档/永平版优化对比与合并说明.md`(新增) |
### 9.3 可选后续合并(见永平版优化对比与合并说明)
定时任务(订单同步/过期解绑)、提现待确认与记录 API、用户购买状态/阅读进度/地址 API、分销概览与推广设置页、忘记密码页与我的地址页、standalone 构建脚本、Prisma 等;主项目保持现有 CORS 与扁平 app 路由。
---
## 十、链路优化与 yongpxu-soul 对照2026-02-20
### 10.1 链路优化(不改文件结构)
- **文档**:已新增 `开发文档/链路优化与运行指南.md`,明确四条链路及落地方式:
- **后台鉴权**admin / key123456store + admin-auth 一致),登出可调 `POST /api/admin/logout`
- **进群**:支付成功后由前端根据 `groupQrCode` / 活码展示或跳转;配置来自 `/api/config` 与后台「二维码管理」(当前存前端 store刷新以接口为准
- **营销策略**:推广、海报、分销比例等以 `api/referral/*``api/db/config` 及 store 配置为准;内容以 `book/``lib/book-data.ts` 为准。
- **支付**create-order → 微信/支付宝 notify → 校验 → 进群/解锁内容;保持现有 `app/api/payment/*``lib/payment*` 不变。
- **协同**:鉴权、进群、营销、支付可多角色并行优化,所有改动限于现有目录与文件,不新增一级目录。
- **运行**:以第一目录为基准,`pnpm dev` / 生产 build+standalone端口 3006详见 `开发文档/本机运行文档.md` 与链路指南内运行检查清单。
### 10.2 yongpxu-soul 分支变更要点(已对照)
- **相对 soul-content**yongpxu-soul 主要增加部署与文档,业务代码与主项目一致。
- 新增:`scripts/deploy_baota.py``开发文档/8、部署/宝塔配置检查说明.md``开发文档/8、部署/当前项目部署到线上.md`、小程序相关miniprogram 上传脚本、开发文档/小程序管理、开发文档/服务器管理)、`开发文档/提现功能完整技术文档.md``lib/wechat-transfer.ts` 等。
- 删除/合并:大量历史部署报告与重复文档(如多份「部署完成」「升级完成」等),功能迭代记录合并精简。
- **结论**:业务链路(鉴权→进群→营销→支付)以**第一目录现有实现**为准yongpxu-soul 的修改用于**部署方式、小程序发布、文档与运维**,不改变主项目文件结构与上述四条链路的代码归属。
- **可运行性**:按《链路优化与运行指南》第七节检查清单自检后,项目可在不修改文件结构的前提下完成落地与运行。
### 10.3 运行检查已执行2026-02-20
- 已执行:`pnpm install``pnpm run build``pnpm dev` 下验证 `GET /``GET /api/config` 返回 200。
- 执行记录详见 `开发文档/链路优化与运行指南.md` 第八节。
- 结论:构建与开发环境运行正常,链路就绪。
---
## 十一、下一步行动计划2026-02-20
| 优先级 | 行动项 | 负责模块 | 说明 |
|--------|--------|----------|------|
| P0 | 生产部署与回调配置 | 支付/部署 | 将当前分支部署至宝塔(或现有环境),配置微信/支付宝回调 URL 指向 `/api/payment/wechat/notify``/api/payment/alipay/notify`,并验证支付→到账→进群展示。 |
| P1 | 进群配置持久化(可选) | 进群/配置 | 若需多环境或刷新不丢失:让 `/api/config` 或单独接口读取/写入 `api/db/config``payment_config.wechatGroupUrl`、活码链接;或后台「二维码管理」保存时调用 db 配置 API。 |
| P1 | 后台「退出登录」对接 | 鉴权 | 在 `app/admin/layout.tsx` 将「返回前台」旁增加「退出登录」按钮,点击请求 `POST /api/admin/logout` 后跳转 `/admin/login`(若后续改为服务端 Cookie 鉴权即可生效)。 |
| P2 | Admin 密码环境变量统一(可选) | 鉴权 | 在 `lib/store.ts``adminLogin` 中从 `process.env.NEXT_PUBLIC_ADMIN_USERNAME` / `NEXT_PUBLIC_ADMIN_PASSWORD` 读取(或通过小 API 校验),与 `lib/admin-auth.ts` 一致。 |
| P2 | 营销与内容迭代 | 营销/内容 | 在现有结构内更新:`book/` 下 Markdown、`lib/book-data.ts` 章节与免费列表、`api/referral/*``api/db/config` 分销/推广配置;后台「系统设置」「内容管理」按需调整。 |
| P2 | 文档与分支同步 | 文档 | 定期将 yongpxu-soul 的部署/小程序/运维文档变更合并到主分支或文档目录,保持《链路优化与运行指南》《本机运行文档》与线上一致。 |
以上按 P0 → P1 → P2 顺序推进P0 完成即可上线跑通整条链路P1/P2 为体验与可维护性增强。
---
## 十二、永平落地2026-02 依据 cursor_1_14
| 任务 | 状态 | 说明 |
|------|------|------|
| 内容管理仅保留「API 接口」按钮 | 已完成 | soul-admin ContentPage 源码改造,移除 5 按钮,新增 API 接口按钮 |
| 侧栏与推广中心页「交易中心」→「推广中心」 | 已完成 | AdminLayout、DistributionPage 文案统一 |
| 分销:海报带用户 ID、复制文案去掉邀请码展示 | 已完成 | referral.js scene 用 userId海报去掉邀请码文案 |
| 我的页:待领收益→我的收益 | 已完成 | my.wxml 未登录卡片文案统一 |
| 后台与前台参数一致(绑定有效期、自动提现、免费章节) | 已检查 | 推广设置、系统设置与 API 对齐 |
| 需求与文档整理 | 已完成 | 需求汇总需求清单、运营与变更第五部分、本推进表十二节 |
| 会员分润差异化(会员 20%/非会员 10% | 已完成 | computeOrderCommission推广设置页 vipOrderShareVip、vipOrderShareNonVip |
| VIP 角色管理、SetVipModal、VIP 排序 | 已完成 | vip_roles 表、VipMembers 页、vip_activated_at/vip_sort |
| 开发进度同步会议 | 已完成 | 2026-02-27 橙子同步至运营与变更第七部分 |
---
## 十三、找伙伴功能完善2026-03-08
| 任务 | 状态 | 说明 |
|------|------|------|
| 后台「找伙伴」统一入口页5 Tab | 已完成 | 数据统计→找伙伴→资源对接→导师预约→团队招募 |
| 找伙伴统计 Tab | 已完成 | 6 统计卡片 + 类型分布 + CKB 7 端点真实测试 |
| 匹配池选择VIP/完善/全部) | 已完成 | 3 来源池 + 4 项完善度开关;显示各池人数 |
| 用户管理 ?pool= 参数筛选 | 已完成 | 支持 ?pool=vip/complete/all 跳转筛选 |
| CKBJoin 写入 match_records | 已完成 | 团队招募/资源对接 ckb/join 成功后同步写入 |
| 小程序「超级个体」改名「找伙伴」 | 已完成 | match.js partner label 更新 |
| 当天已匹配不重复 | 已完成 | MatchUsers 排除当天已匹配 matched_user_id |
| 存客宝协作需求文档 | 已完成 | 4 条需求写入存客宝协作需求.md |
| CKB 测试"已存在"判定修正 | 已完成 | 前端:已存在/已加入也标为成功 |
| 匹配记录加载失败修复 | 已完成 | 后端 DBMatchRecordsList 对空用户做安全读取,避免 nil panic |
| 存客宝右上角工作台 | 已完成 | 从独立 Tab 改为右上角入口;支持接口测试、配置保存、文档摘要 |
| 存客宝场景配置列表化 | 已完成 | 每个入口独立 apiUrl/apiKey/source/tags/siteTags/notes可保存到 ckb_config.routes |
| CKB 明细接口 | 已完成 | 新增 /api/db/ckb-leads支持已提交线索 / 有联系方式明细查看 |
| 存客宝入口位置调整 | 已完成 | 从主 Tab 改回右上角按钮入口,点击打开存客宝工作台 |
| 存客宝工作台子页化 | 已完成 | 概览 / 已提交线索 / 有联系方式 / 场景配置 / 接口测试 / API 文档 六块独立 |
| AI 获客数据首页重构 | 已完成 | 数据统计页拆为「找伙伴数据 / AI 获客数据」,已提交线索和有联系方式可点开 |
| 本地测试数据插入能力 | 已完成 | 新增 /api/db/match-records/test资源对接/团队招募页可一键插入测试记录 |
| Dashboard 增加匹配概览 | 已完成 | 首页数据概览新增「匹配次数」「匹配收益」 |
---
## 十四、内容管理深度优化2026-03-07 ~ 2026-03-09
### 14.1 排名算法可配置化03-07
| 任务 | 状态 | 说明 |
|------|------|------|
| 排名算法权重可编辑 | 已完成 | 阅读/新度/付款三权重可在后台直接修改,权重存 system_config |
| 数据填充(点击量/付款数) | 已完成 | reading_progress + orders 表关联,排行榜显示点击量、付款数、热度 |
| 批量移动修复 | 已完成 | 修复「移动失败缺少ID」问题SectionIds 正确传递 |
| 2026每日派对干货板块一致性 | 已完成 | 新建/删除/编辑功能与其他板块保持一致 |
| 后台整体优化 | 已完成 | 界面美化、交互优化、暗色主题深度定制 |
### 14.2 内容管理五项修改03-08 第一批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 删除「钩子设置」Tab → 新增「内容排行榜」Tab | 已完成 | 排行榜按热度排序分页10节/页,显示点击数据 |
| 拖拽排序与后端同步修复 | 已完成 | 章节树拖拽排序结果正确写入数据库 |
| 未付费预览比例可配置 | 已完成 | system_config 存 unpaid_preview_percent后台可修改 |
| 排名权重可编辑 + 精选推荐/首页置顶 | 已完成 | 置顶用 Star 图标标识pinned_section_ids 存配置 |
| 合并预览/编辑按钮 + 章节ID可编辑 | 已完成 | 单按钮打开编辑弹窗ID 字段可直接修改 |
### 14.3 内容管理五项修改03-08 第二批)
| 任务 | 状态 | 说明 |
|------|------|------|
| Tab 顺序调整 | 已完成 | 章节管理 → 内容排行榜 → 内容搜索 |
| 置顶状态全局显示Star图标 | 已完成 | 章节树、排行榜、搜索结果均显示 Star |
| 排名积分逻辑细化 | 已完成 | 最近更新30分递减/阅读量20分递减/付款数20分递减 + 手动覆盖 |
| 富文本编辑器升级 | 已完成 | TipTap 编辑器,支持格式化/图片/表格/@提及/#链接标签 |
| 人物列表 + 链接标签管理 | 已完成 | persons/link_tags 表 CRUD后台管理界面 |
### 14.4 内容管理三项修改03-09 第三批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 排行榜操作改为「编辑文章」 | 已完成 | 原「付款记录」按钮移入编辑弹窗底部 |
| 章节ID修改确保保存成功 | 已完成 | 前端 originalId 机制 + 后端 newId 字段支持 |
| 付款记录用户ID/订单ID可点击跳转 | 已完成 | 用户名截短显示,点击跳转用户详情/订单详情 |
### 14.5 链接AI Tab03-09 第四批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 「主人公」Tab → 「链接AI」Tab | 已完成 | 链接人与事AI列表 + 链接标签管理 |
| 人物ID改为可选 | 已完成 | 名称必填ID自动生成后端兼容 |
| 链接标签新增「存客宝」类型 | 已完成 | 支持 url/miniprogram/ckb 三种类型 |
| 存客宝绑定配置面板 | 已完成 | 显示API地址和绑定计划跳转存客宝工作台 |
| 预填充数据 | 已完成 | 卡若/南风/远志/老墨/荷总/永平 + 神仙团队/Soul派对房/飞书中台/超级个体 |
---
## 十五、存客宝集成技术方案
### 15.1 概述
存客宝CKB是第三方获客工具通过 API 上报线索到微信生态中实现自动加好友/拉群。本项目在以下场景集成:
1. **找伙伴功能**:匹配成功 → 上报存客宝场景 → 自动加好友
2. **内容管理「链接AI」**:文章内 @人物 / #标签 → 点击跳转存客宝链接 → 进入流量池
### 15.2 核心 API
| 接口 | 方法 | 地址 | 说明 |
|------|------|------|------|
| 场景获客 | POST | `https://ckbapi.quwanzhi.com/v1/api/scenarios` | 上报线索(手机号/微信号等) |
| 线索查询 | GET | `/v1/api/lead/query` | 按手机号/微信号查询状态(待开发) |
| 批量统计 | GET | `/v1/api/lead/stats` | 时间段内线索统计(待确认) |
| 自动加好友 | POST | `/v1/api/lead/add-friend` | 匹配后自动发起好友申请(待确认) |
### 15.3 后台配置
- **存客宝场景配置**`找伙伴 → 存客宝工作台` 中管理 apiUrl/apiKey/source/tags 等
- **内容链接绑定**`内容管理 → 链接AI → 存客宝绑定` 面板配置计划绑定
- **链接标签类型 = ckb**link_tags 表 type 支持 `url`/`miniprogram`/`ckb`
### 15.4 数据库表
- `persons`AI人物列表person_id, name, label
- `link_tags`链接标签tag_id, label, url, type[url/miniprogram/ckb], app_id, page_path
- `system_config`存客宝相关配置ckb_config.routes, ckb_api_key 等)
详细协作需求见 `存客宝协作需求.md`

BIN
开发文档/1、需求/.DS_Store vendored Normal file

Binary file not shown.

BIN
开发文档/1、需求/修改/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,22 @@
功能三:
然后完成的时候,那个我们现在完成之后用复盘的那个格式。复盘的格式有完成之后用复盘的格式放到。用复盘的格式发到这里,开这个卡洛创业派对开发资料的这个,然后这个。那这个发到这个上面来,每次生成复盘的格式都发到这个上面来。并且这个开发在那个创业派对开发的过程当中,都是和这个飞书的群复盘的,都是和飞书的群绑定的
![](images/2026-03-15-09-03-58.png)的格式,参考这个格式,复盘的格式参考一下这个格式。
https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494
复盘:
功能二:分享功能优化
![](images/2026-03-15-09-01-19.png)
然后这个点击分享到朋友圈这边的话是获得收获得90%收益是要放到下方的这个分享这里应该改成分享给好友分享到朋友圈替换成分享给好友。然后这里的话如果有看书拉到20%的位置的时候有向下拉的行为的时候这个就会跳出分享的90%收益。的一个小的一个提示。
bug修复一
![](images/2026-03-15-08-58-59.png)
![](images/2026-03-15-08-59-21.png)
![](images/2026-03-15-08-59-41.png)
这个内容管理里面的那个星星点点,跟下划线,去掉星星点点这类型的上传上去,下滑线去掉,然后看检查一下这个我们这个内容文档的那个格式,检查一下内容文档的这个格式,然后把这个直接去掉。把这些这个内容直接去掉,然后第二个的话只点击这里的话,像点击 add 咱们后台的这个 add 功能,点击 add 是无法添加,没有反应这个以及这个 add 自动解析的这一个问题要帮我处理一下点击 add 的这一个有爱的源自这一块。然后点击派对会员这边。点击派对会员。会早会提醒是无法找到那个小程序配置,帮我把这个也帮我看一下,优化一下,这是这方面内容的这方面的这一个功能。那个你要解析清楚,看一下我这个后台,这个编辑器的这个格式,我这样上传文本文档上去,那个格式跟图标跟那些东西得清晰进行转化,然后那个已经完成了这个 API 的相应的那个接口,直接用 API 的接口来进行那个上传。然后把这个 API 的东西更新一下,到我们那个上传文章的这个上面去。
功能一:代付款让好友看免费
就这个抖音副业的这一块还可以做一个事情是什么就抖音这副业的这一块还可以做一个事情你现在你要给别人看嘛别人看你觉得有趣给别人看别人付钱那你帮别人付钱帮他代付解锁让他能看你来付一块钱帮他代付100那他就可以打开看他就不用付这一块钱了。然后那他是不是就会看了就有感觉的吗就像流光一样我给你付一块你去发然后帮他代付一块就可以了。那他是就会更用心的。看完之后别人觉得这个东西能做还可以付个100吗然后就发给100个人看他也可以这个就是付款余额嗯。能不能提现不能提。可以提现的它可以充值可以提现充值就不能提了比如说我充重庆小面我充100我还能提现吗不能提了可以退款吗不能提9折。把葱的话就抽100还是9折抽还是买就100吗你这样充值就足够多了。对呀。是不是

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Some files were not shown because too many files have changed in this diff Show More