实现@提及功能,允许用户在阅读页中高亮并点击提及的用户,触发添加好友流程。更新内容解析逻辑以支持提及格式,调整页面展示以适应新功能,并优化样式以提升用户体验。
This commit is contained in:
@@ -8,6 +8,10 @@
|
|||||||
* - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完
|
* - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完
|
||||||
* - 使用状态机(accessState)规范权限流转
|
* - 使用状态机(accessState)规范权限流转
|
||||||
* - 异常统一保守处理,避免误解锁
|
* - 异常统一保守处理,避免误解锁
|
||||||
|
*
|
||||||
|
* 更新: 正文 @某人({{@userId:昵称}})
|
||||||
|
* - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
|
||||||
|
* - 回归:无@ 时仍按段展示;未登录/无联系方式/重复点击均有提示或去重
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import accessManager from '../../utils/chapterAccessManager'
|
import accessManager from '../../utils/chapterAccessManager'
|
||||||
@@ -16,6 +20,25 @@ const { parseScene } = require('../../utils/scene.js')
|
|||||||
|
|
||||||
const app = getApp()
|
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({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
// 系统信息
|
// 系统信息
|
||||||
@@ -32,6 +55,7 @@ Page({
|
|||||||
content: '',
|
content: '',
|
||||||
previewContent: '',
|
previewContent: '',
|
||||||
contentParagraphs: [],
|
contentParagraphs: [],
|
||||||
|
contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}]
|
||||||
previewParagraphs: [],
|
previewParagraphs: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
|
|
||||||
@@ -214,6 +238,7 @@ Page({
|
|||||||
const updates = {
|
const updates = {
|
||||||
content: res.content,
|
content: res.content,
|
||||||
contentParagraphs: lines,
|
contentParagraphs: lines,
|
||||||
|
contentSegments: lines.map(parseLineToSegments),
|
||||||
previewParagraphs: lines.slice(0, previewCount),
|
previewParagraphs: lines.slice(0, previewCount),
|
||||||
partTitle: res.partTitle || '',
|
partTitle: res.partTitle || '',
|
||||||
chapterTitle: res.chapterTitle || ''
|
chapterTitle: res.chapterTitle || ''
|
||||||
@@ -239,6 +264,7 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
content: cached.content,
|
content: cached.content,
|
||||||
contentParagraphs: lines,
|
contentParagraphs: lines,
|
||||||
|
contentSegments: lines.map(parseLineToSegments),
|
||||||
previewParagraphs: lines.slice(0, previewCount)
|
previewParagraphs: lines.slice(0, previewCount)
|
||||||
})
|
})
|
||||||
console.log('[Read] 从本地缓存加载成功')
|
console.log('[Read] 从本地缓存加载成功')
|
||||||
@@ -343,6 +369,7 @@ Page({
|
|||||||
// 3. 都失败,显示加载中并持续重试
|
// 3. 都失败,显示加载中并持续重试
|
||||||
this.setData({
|
this.setData({
|
||||||
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
|
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
|
||||||
|
contentSegments: [parseLineToSegments('章节内容加载中...'), parseLineToSegments('正在尝试连接服务器,请稍候...')],
|
||||||
previewParagraphs: ['章节内容加载中...']
|
previewParagraphs: ['章节内容加载中...']
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -387,6 +414,7 @@ Page({
|
|||||||
content: res.content,
|
content: res.content,
|
||||||
previewContent: lines.slice(0, previewCount).join('\n'),
|
previewContent: lines.slice(0, previewCount).join('\n'),
|
||||||
contentParagraphs: lines,
|
contentParagraphs: lines,
|
||||||
|
contentSegments: lines.map(parseLineToSegments),
|
||||||
previewParagraphs: lines.slice(0, previewCount),
|
previewParagraphs: lines.slice(0, previewCount),
|
||||||
partTitle: res.partTitle || '',
|
partTitle: res.partTitle || '',
|
||||||
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
|
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
|
||||||
@@ -412,6 +440,7 @@ Page({
|
|||||||
if (currentRetry >= maxRetries) {
|
if (currentRetry >= maxRetries) {
|
||||||
this.setData({
|
this.setData({
|
||||||
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
|
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
|
||||||
|
contentSegments: [parseLineToSegments('内容加载失败'), parseLineToSegments('请检查网络连接后下拉刷新重试')],
|
||||||
previewParagraphs: ['内容加载失败']
|
previewParagraphs: ['内容加载失败']
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -466,6 +495,95 @@ Page({
|
|||||||
getApp().goBackOrToHome()
|
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() {
|
showShare() {
|
||||||
this.setData({ showShareModal: true })
|
this.setData({ showShareModal: true })
|
||||||
|
|||||||
@@ -42,10 +42,13 @@
|
|||||||
<view class="skeleton skeleton-5"></view>
|
<view class="skeleton skeleton-5"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 完整内容 - 免费或已购买 -->
|
<!-- 完整内容 - 免费或已购买(支持 @ 高亮可点) -->
|
||||||
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
|
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
|
||||||
<view class="paragraph" wx:for="{{contentParagraphs}}" wx:key="index" wx:if="{{item}}">
|
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
|
||||||
{{item}}
|
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
|
||||||
|
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
|
||||||
|
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 章节导航 -->
|
<!-- 章节导航 -->
|
||||||
|
|||||||
@@ -182,6 +182,13 @@
|
|||||||
text-align: justify;
|
text-align: justify;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 正文内 @某人 高亮可点 */
|
||||||
|
.paragraph .mention {
|
||||||
|
color: #00CED1;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ func Load() (*Config, error) {
|
|||||||
if adminSessionSecret == "" {
|
if adminSessionSecret == "" {
|
||||||
adminSessionSecret = "soul-admin-secret-change-in-prod"
|
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 s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" {
|
||||||
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
|
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
|
||||||
syncOrdersInterval = n
|
syncOrdersInterval = n
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"soul-api/internal/wechat"
|
"soul-api/internal/wechat"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -44,8 +45,104 @@ func SyncOrdersLogf(format string, args ...interface{}) {
|
|||||||
syncOrdersLogf(format, args...)
|
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 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
||||||
// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。
|
// 可被 HTTP 接口和内置定时任务调用;日常以 PollOrderUntilPaidOrTimeout 单笔轮询为主,本方法作兜底。
|
||||||
func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) {
|
func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) {
|
||||||
if days < 1 {
|
if days < 1 {
|
||||||
days = 7
|
days = 7
|
||||||
@@ -75,7 +172,6 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
|
|||||||
if tradeState != "SUCCESS" {
|
if tradeState != "SUCCESS" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 微信已支付,本地未更新 → 补齐
|
|
||||||
totalAmount := float64(totalFee) / 100
|
totalAmount := float64(totalFee) / 100
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
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++
|
synced++
|
||||||
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
|
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
|
||||||
|
processOrderPaidPostProcess(db, &o, transactionID, 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)
|
|
||||||
}
|
}
|
||||||
return synced, total, nil
|
return synced, total, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ func miniprogramPayPost(c *gin.Context) {
|
|||||||
"payParams": payParams,
|
"payParams": payParams,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// 用户支付发起后,仅轮询该笔订单直到微信返回已支付或超时(防漏单)
|
||||||
|
go PollOrderUntilPaidOrTimeout(orderSn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效)
|
// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效)
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ func UserCheckPurchased(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||||
return
|
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
|
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||||
if hasFullBook {
|
if hasFullBook {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
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 {
|
if user.PendingEarnings != nil {
|
||||||
pendingEarnings = *user.PendingEarnings
|
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{
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||||
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
|
"hasFullBook": hasFullBook,
|
||||||
"purchasedSections": purchasedSections,
|
"purchasedSections": purchasedSections,
|
||||||
"sectionMidMap": sectionMidMap,
|
"sectionMidMap": sectionMidMap,
|
||||||
"purchasedCount": len(purchasedSections),
|
"purchasedCount": len(purchasedSections),
|
||||||
|
|||||||
Reference in New Issue
Block a user