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
BIN
20260226一场.zip
Normal 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}` : '',
|
||||
|
||||
@@ -57,7 +57,10 @@
|
||||
]
|
||||
},
|
||||
"usingComponents": {},
|
||||
"navigateToMiniProgramAppIdList": [],
|
||||
"navigateToMiniProgramAppIdList": [
|
||||
"wx6489c26045912fe1",
|
||||
"wx3d15ed02e98b04e3"
|
||||
],
|
||||
"__usePrivacyCheck__": true,
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"style": "v2",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -203,6 +221,12 @@ Page({
|
||||
: 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 }
|
||||
},
|
||||
|
||||
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
|
||||
@@ -1358,6 +1462,83 @@ Page({
|
||||
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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
"name": "唤醒",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=209",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/my/my",
|
||||
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
# 飞书应用凭证(卡若私域)- 勿提交到公开仓库
|
||||
FEISHU_APP_ID=cli_a48818290ef8100d
|
||||
FEISHU_APP_SECRET=dhjU0qWd5AzicGWTf4cTqhCWJOrnuCk4
|
||||
FEISHU_WIKI_NODE_TOKEN=FNP6wdvNKij7yMkb3xCce0CYnpd
|
||||
75
scripts/fix_duplicate_title_upload.py
Normal 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()
|
||||
151
scripts/migrate_2026_sections.py
Normal 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-daily,id 为 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()
|
||||
@@ -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拿"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
1
soul-admin/dist/assets/index-Bd1cCYoa.css
vendored
1
soul-admin/dist/assets/index-BlPUt9ll.css
vendored
Normal file
792
soul-admin/dist/assets/index-ChSYyP1O.js
vendored
Normal file
779
soul-admin/dist/assets/index-DJPaWrh0.js
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">表单:file(必填)、folder(可选,默认 images)、quality(可选 1-100,默认 85)</p>
|
||||
<p className="text-gray-500 text-xs">支持 jpeg/png/gif,单张最大 5MB。JPEG 按 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">表单:file(必填)、folder(可选,默认 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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
352
soul-api/internal/handler/balance.go
Normal 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(原额 ¥%.2f,9折退回 ¥%.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,
|
||||
}})
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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{
|
||||
|
||||
269
soul-api/internal/handler/upload_content.go
Normal 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
|
||||
}
|
||||
44
soul-api/internal/model/balance.go
Normal 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" }
|
||||
@@ -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
BIN
soul-api/soul-api-new
Executable file
BIN
开发文档/.DS_Store
vendored
Normal file
36
开发文档/.github/workflows/sync_from_coding.yml
vendored
Normal 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
3738
开发文档/10、项目管理/产研团队 第21场 20260129 许永平.txt
Normal file
102
开发文档/10、项目管理/存客宝协作需求.md
Normal 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. 预计的开发/对接时间
|
||||
|
||||
---
|
||||
|
||||
## 发送方式
|
||||
|
||||
将本文档发给存客宝技术团队(或神射手),确认排期后更新状态。
|
||||
BIN
开发文档/10、项目管理/小程序20260129-2.pdf
Normal file
BIN
开发文档/10、项目管理/小程序20260129.pdf
Normal 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、需求汇总、运营与变更同步更新。
|
||||
|
||||
58
开发文档/10、项目管理/项目管理提示词.md
Normal 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. **复盘**:项目结束时,根据执行表生成《项目复盘报告》。
|
||||
553
开发文档/10、项目管理/项目落地推进表.md
Normal 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.10(App Router)
|
||||
- React 19
|
||||
- TypeScript 5.9.3
|
||||
- Tailwind CSS v4
|
||||
|
||||
**UI组件:**
|
||||
- Radix UI(无头组件库)
|
||||
- Lucide React(图标)
|
||||
- Zustand(状态管理)
|
||||
|
||||
**支付集成:**
|
||||
- 支付宝手机网站支付(MD5签名)
|
||||
- 微信Native支付(XML格式)
|
||||
- 自研支付SDK服务类
|
||||
|
||||
**开发工具:**
|
||||
- Gray Matter(Markdown解析)
|
||||
- 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 / key123456(store + 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 Tab(03-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
BIN
开发文档/1、需求/修改/.DS_Store
vendored
Normal file
22
开发文档/1、需求/修改/20260314内容管理5.md
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
功能三:
|
||||
然后完成的时候,那个我们现在完成之后用复盘的那个格式。复盘的格式有完成之后用复盘的格式放到。用复盘的格式发到这里,开这个卡洛创业派对开发资料的这个,然后这个。那这个发到这个上面来,每次生成复盘的格式都发到这个上面来。并且这个开发在那个创业派对开发的过程当中,都是和这个飞书的群复盘的,都是和飞书的群绑定的
|
||||
的格式,参考这个格式,复盘的格式参考一下这个格式。
|
||||
https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494
|
||||
|
||||
复盘:
|
||||
|
||||
功能二:分享功能优化
|
||||

|
||||
然后这个点击分享到朋友圈这边的话是获得收,获得90%收益是要放到下方的,这个分享这里应该改成分享给好友,分享到朋友圈,替换成分享给好友。然后这里的话如果有看书拉到20%的位置的时候,有向下拉的行为的时候,这个就会跳出分享的90%收益。的一个小的一个提示。
|
||||
|
||||
|
||||
bug修复一:
|
||||

|
||||

|
||||

|
||||
这个内容管理里面的那个星星点点,跟下划线,去掉星星点点这类型的上传上去,下滑线去掉,然后看检查一下这个我们这个内容文档的那个格式,检查一下内容文档的这个格式,然后把这个直接去掉。把这些这个内容直接去掉,然后第二个的话只点击这里的话,像点击 add 咱们后台的这个 add 功能,点击 add 是无法添加,没有反应这个以及这个 add 自动解析的这一个问题要帮我处理一下点击 add 的这一个有爱的源自这一块。然后点击派对会员这边。点击派对会员。会早会提醒是无法找到那个小程序配置,帮我把这个也帮我看一下,优化一下,这是这方面内容的这方面的这一个功能。那个你要解析清楚,看一下我这个后台,这个编辑器的这个格式,我这样上传文本文档上去,那个格式跟图标跟那些东西得清晰进行转化,然后那个已经完成了这个 API 的相应的那个接口,直接用 API 的接口来进行那个上传。然后把这个 API 的东西更新一下,到我们那个上传文章的这个上面去。
|
||||
|
||||
|
||||
功能一:代付款让好友看免费
|
||||
就这个抖音副业的这一块还可以做一个事情,是什么?就抖音这副业的这一块还可以做一个事情,你现在你要给别人看嘛?别人看你觉得有趣,给别人看别人付钱,那你帮别人付钱,帮他代付解锁,让他能看你来付一块钱,帮他代付100,那他就可以打开看,他就不用付这一块钱了。然后那他是不是就会看了就有感觉的吗?就像流光一样,来,我给你付一块,你去发,然后帮他代付一块就可以了。那他是就会更用心的。看完之后别人觉得,哎,这个东西能做,还可以付个100吗?然后就发给100个人看,对,他也可以,这个就是付款余额,嗯。能不能提现?不能提。可以提现的,它可以充值,可以提现,充值就不能提了,比如说我充重庆小面,我充100,我还能提现吗?不能提了可以退款吗?不能提,9折。把葱的话就抽100还是9折抽,还是买就100吗?对,你这样充值就足够多了。对呀。是不是?
|
||||
BIN
开发文档/1、需求/修改/Soul 20260118.pdf
Normal file
BIN
开发文档/1、需求/修改/images/`.png
Normal file
|
After Width: | Height: | Size: 575 KiB |
0
开发文档/1、需求/修改/images/.gitkeep
Normal file
BIN
开发文档/1、需求/修改/images/2026-03-08-08-02-44.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-05-57.png
Normal file
|
After Width: | Height: | Size: 575 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-08-36.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-11-21.png
Normal file
|
After Width: | Height: | Size: 554 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-16-43.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-16-47.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-35-20.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-37-41.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-42-59.png
Normal file
|
After Width: | Height: | Size: 465 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-08-45-48.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-09-07-07.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-09-07-58.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-09-10-04.png
Normal file
|
After Width: | Height: | Size: 336 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-08-47.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-17-14.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-18-28.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-21-19.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-28-11.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-30-34.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-31-24.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-43-44.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-44-05.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-46-02.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-47-24.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-56-15.png
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-58-04.png
Normal file
|
After Width: | Height: | Size: 527 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-10-59-57.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-17-15.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-18-04.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-19-38.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-20-02.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-22-09.png
Normal file
|
After Width: | Height: | Size: 581 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-30-23.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-31-27.png
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-32-34.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-11-32-54.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-15-58-20.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-15-59-01.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-15-59-53.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-02-09.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-14-58.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-15-14.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-15-31.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-18-08.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-30-11.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-31-01.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-32-02.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-08-16-33-49.png
Normal file
|
After Width: | Height: | Size: 516 KiB |