diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 3c342f51..21080139 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -2,12 +2,16 @@ * Soul创业派对 - 阅读页(标准流程版) * 开发: 卡若 * 技术支持: 存客宝 - * + * * 更新: 2026-02-04 * - 引入权限管理器(chapterAccessManager)统一权限判断 * - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完 * - 使用状态机(accessState)规范权限流转 * - 异常统一保守处理,避免误解锁 + * + * 更新: 正文 @某人({{@userId:昵称}}) + * - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead + * - 回归:无@ 时仍按段展示;未登录/无联系方式/重复点击均有提示或去重 */ import accessManager from '../../utils/chapterAccessManager' @@ -16,6 +20,25 @@ const { parseScene } = require('../../utils/scene.js') const app = getApp() +// 解析单行中的 {{@userId:昵称}} 为片段数组,用于阅读页 @ 高亮与点击 +function parseLineToSegments(line) { + const segments = [] + const re = /\{\{@([^:]+):(.*?)\}\}/g + let lastEnd = 0 + let m + while ((m = re.exec(line)) !== null) { + if (m.index > lastEnd) { + segments.push({ type: 'text', text: line.slice(lastEnd, m.index) }) + } + segments.push({ type: 'mention', userId: String(m[1]).trim(), nickname: String(m[2] || '').trim() }) + lastEnd = re.lastIndex + } + if (lastEnd < line.length) { + segments.push({ type: 'text', text: line.slice(lastEnd) }) + } + return segments.length ? segments : [{ type: 'text', text: line }] +} + Page({ data: { // 系统信息 @@ -32,6 +55,7 @@ Page({ content: '', previewContent: '', contentParagraphs: [], + contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}] previewParagraphs: [], loading: true, @@ -214,6 +238,7 @@ Page({ const updates = { content: res.content, contentParagraphs: lines, + contentSegments: lines.map(parseLineToSegments), previewParagraphs: lines.slice(0, previewCount), partTitle: res.partTitle || '', chapterTitle: res.chapterTitle || '' @@ -239,6 +264,7 @@ Page({ this.setData({ content: cached.content, contentParagraphs: lines, + contentSegments: lines.map(parseLineToSegments), previewParagraphs: lines.slice(0, previewCount) }) console.log('[Read] 从本地缓存加载成功') @@ -343,6 +369,7 @@ Page({ // 3. 都失败,显示加载中并持续重试 this.setData({ contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'], + contentSegments: [parseLineToSegments('章节内容加载中...'), parseLineToSegments('正在尝试连接服务器,请稍候...')], previewParagraphs: ['章节内容加载中...'] }) @@ -387,6 +414,7 @@ Page({ content: res.content, previewContent: lines.slice(0, previewCount).join('\n'), contentParagraphs: lines, + contentSegments: lines.map(parseLineToSegments), previewParagraphs: lines.slice(0, previewCount), partTitle: res.partTitle || '', // 导航栏、分享等使用的文章标题,同样统一为 sectionTitle @@ -412,6 +440,7 @@ Page({ if (currentRetry >= maxRetries) { this.setData({ contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'], + contentSegments: [parseLineToSegments('内容加载失败'), parseLineToSegments('请检查网络连接后下拉刷新重试')], previewParagraphs: ['内容加载失败'] }) return @@ -466,6 +495,95 @@ Page({ getApp().goBackOrToHome() }, + // 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资 + onMentionTap(e) { + const userId = e.currentTarget.dataset.userId + const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA' + if (!userId) return + wx.showModal({ + title: '添加好友', + content: `是否添加 @${nickname} ?`, + confirmText: '确定', + cancelText: '取消', + success: (res) => { + if (!res.confirm) return + this._doMentionAddFriend(userId, nickname) + } + }) + }, + + // 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重 + async _doMentionAddFriend(targetUserId, targetNickname) { + 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 myUserId = app.globalData.userInfo.id + 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=${myUserId}`, 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 + } + const leadKey = `mention_lead_${myUserId}_${targetUserId}` + if (wx.getStorageSync(leadKey)) { + wx.showToast({ title: '已提交过,对方会尽快联系您', icon: 'none' }) + return + } + wx.showLoading({ title: '提交中...', mask: true }) + try { + const res = await app.request({ + url: '/api/miniprogram/ckb/lead', + method: 'POST', + data: { + userId: myUserId, + phone: phone || undefined, + wechatId: wechatId || undefined, + name: (app.globalData.userInfo.nickname || '').trim() || undefined, + targetUserId, + targetNickname: targetNickname || undefined, + source: 'article_mention' + } + }) + wx.hideLoading() + if (res && res.success) { + wx.setStorageSync(leadKey, true) + 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 }) diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index ddf4799f..7217d466 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -42,10 +42,13 @@ - + - - {{item}} + + + {{seg.text}} + @{{seg.nickname}} + diff --git a/miniprogram/pages/read/read.wxss b/miniprogram/pages/read/read.wxss index 8b0ecca2..0056c670 100644 --- a/miniprogram/pages/read/read.wxss +++ b/miniprogram/pages/read/read.wxss @@ -182,6 +182,13 @@ text-align: justify; } +/* 正文内 @某人 高亮可点 */ +.paragraph .mention { + color: #00CED1; + font-weight: 500; + padding: 0 4rpx; +} + .preview { position: relative; } diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go index d3b4f252..a1554883 100644 --- a/soul-api/internal/config/config.go +++ b/soul-api/internal/config/config.go @@ -222,7 +222,8 @@ func Load() (*Config, error) { if adminSessionSecret == "" { adminSessionSecret = "soul-admin-secret-change-in-prod" } - syncOrdersInterval := 5 + // 默认 0:不启动全量定时对账,以「支付发起后单笔订单轮询」为主;需兜底时可设 SYNC_ORDERS_INTERVAL_MINUTES=60 等 + syncOrdersInterval := 0 if s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" { if n, e := strconv.Atoi(s); e == nil && n >= 0 { syncOrdersInterval = n diff --git a/soul-api/internal/handler/cron.go b/soul-api/internal/handler/cron.go index 71593834..4a69e401 100644 --- a/soul-api/internal/handler/cron.go +++ b/soul-api/internal/handler/cron.go @@ -16,6 +16,7 @@ import ( "soul-api/internal/wechat" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) var ( @@ -44,8 +45,104 @@ func SyncOrdersLogf(format string, args ...interface{}) { syncOrdersLogf(format, args...) } +// processOrderPaidPostProcess 订单已支付后的统一后置逻辑:全书/VIP/匹配/章节权益、取消同商品未支付订单、分佣 +func processOrderPaidPostProcess(db *gorm.DB, o *model.Order, transactionID string, totalAmount float64) { + pt := "fullbook" + if o.ProductType != "" { + pt = o.ProductType + } + productID := "" + if o.ProductID != nil { + productID = *o.ProductID + } + if productID == "" { + productID = "fullbook" + } + now := time.Now() + + switch pt { + case "fullbook": + db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true) + syncOrdersLogf("用户已购全书: %s", o.UserID) + case "vip": + expireDate := now.AddDate(0, 0, 365) + db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{ + "is_vip": true, + "vip_expire_date": expireDate, + "vip_activated_at": now, + }) + syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02")) + case "match": + syncOrdersLogf("用户购买匹配次数: %s", o.UserID) + case "section": + syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID) + } + + db.Where( + "user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?", + o.UserID, pt, productID, "created", o.OrderSN, + ).Delete(&model.Order{}) + + processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, o) +} + +// PollOrderUntilPaidOrTimeout 用户支付发起后,仅轮询该笔订单直到微信返回已支付或超时(防漏单,替代频繁全量扫描) +// 轮询间隔 8 秒,总超时 6 分钟;若微信已支付则更新订单并执行与 PayNotify 一致的后置逻辑 +func PollOrderUntilPaidOrTimeout(orderSn string) { + const pollInterval = 8 * time.Second + const pollTimeout = 6 * time.Minute + ctx, cancel := context.WithTimeout(context.Background(), pollTimeout) + defer cancel() + + db := database.DB() + for { + select { + case <-ctx.Done(): + return + default: + } + qCtx, qCancel := context.WithTimeout(ctx, 15*time.Second) + tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(qCtx, orderSn) + qCancel() + if qerr != nil { + syncOrdersLogf("轮询查询订单 %s 失败: %v", orderSn, qerr) + time.Sleep(pollInterval) + continue + } + if tradeState == "SUCCESS" { + var order model.Order + if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err != nil { + syncOrdersLogf("轮询订单 %s 查库失败: %v", orderSn, err) + return + } + if order.Status != nil && *order.Status == "paid" { + return + } + now := time.Now() + if err := db.Model(&order).Updates(map[string]interface{}{ + "status": "paid", + "transaction_id": transactionID, + "pay_time": now, + "updated_at": now, + }).Error; err != nil { + syncOrdersLogf("轮询更新订单 %s 失败: %v", orderSn, err) + return + } + totalAmount := float64(totalFee) / 100 + syncOrdersLogf("轮询补齐: %s, amount=%.2f", orderSn, totalAmount) + processOrderPaidPostProcess(db, &order, transactionID, totalAmount) + return + } + switch tradeState { + case "CLOSED", "REVOKED", "PAYERROR": + return + } + time.Sleep(pollInterval) + } +} + // RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单) -// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。 +// 可被 HTTP 接口和内置定时任务调用;日常以 PollOrderUntilPaidOrTimeout 单笔轮询为主,本方法作兜底。 func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) { if days < 1 { days = 7 @@ -75,7 +172,6 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) if tradeState != "SUCCESS" { continue } - // 微信已支付,本地未更新 → 补齐 totalAmount := float64(totalFee) / 100 now := time.Now() if err := db.Model(&o).Updates(map[string]interface{}{ @@ -89,45 +185,7 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) } synced++ syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount) - - // 同步后续逻辑(全书、VIP、分销等,与 PayNotify 一致) - pt := "fullbook" - if o.ProductType != "" { - pt = o.ProductType - } - productID := "" - if o.ProductID != nil { - productID = *o.ProductID - } - if productID == "" { - productID = "fullbook" - } - - switch pt { - case "fullbook": - db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true) - syncOrdersLogf("用户已购全书: %s", o.UserID) - case "vip": - expireDate := now.AddDate(0, 0, 365) - db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{ - "is_vip": true, - "vip_expire_date": expireDate, - "vip_activated_at": now, - }) - syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02")) - case "match": - syncOrdersLogf("用户购买匹配次数: %s", o.UserID) - case "section": - syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID) - } - - // 取消同商品未支付订单(与 PayNotify 一致) - db.Where( - "user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?", - o.UserID, pt, productID, "created", o.OrderSN, - ).Delete(&model.Order{}) - - processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o) + processOrderPaidPostProcess(db, &o, transactionID, totalAmount) } return synced, total, nil } diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go index f0c5558b..5014ef69 100644 --- a/soul-api/internal/handler/miniprogram.go +++ b/soul-api/internal/handler/miniprogram.go @@ -343,6 +343,8 @@ func miniprogramPayPost(c *gin.Context) { "payParams": payParams, }, }) + // 用户支付发起后,仅轮询该笔订单直到微信返回已支付或超时(防漏单) + go PollOrderUntilPaidOrTimeout(orderSn) } // GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效) diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go index 8410f4d9..3acac98d 100644 --- a/soul-api/internal/handler/user.go +++ b/soul-api/internal/handler/user.go @@ -166,6 +166,11 @@ func UserCheckPurchased(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } + // 超级VIP(管理端开通):is_vip=1 且 vip_expire_date>NOW 时,所有文章阅读免费,无需再查订单 + if user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) { + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}}) + return + } hasFullBook := user.HasFullBook != nil && *user.HasFullBook if hasFullBook { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}}) @@ -377,8 +382,13 @@ func UserPurchaseStatus(c *gin.Context) { if user.PendingEarnings != nil { pendingEarnings = *user.PendingEarnings } + // 超级VIP(管理端开通):与 check-purchased 一致,视为全章可读 + hasFullBook := user.HasFullBook != nil && *user.HasFullBook + if !hasFullBook && user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) { + hasFullBook = true + } c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ - "hasFullBook": user.HasFullBook != nil && *user.HasFullBook, + "hasFullBook": hasFullBook, "purchasedSections": purchasedSections, "sectionMidMap": sectionMidMap, "purchasedCount": len(purchasedSections),