优化小程序阅读页面逻辑,动态更新章节价格和免费状态,确保用户体验流畅。移除硬编码价格,支持通过接口返回的值进行展示。更新用户地址管理逻辑,简化获取地址的流程,提升代码可读性。

This commit is contained in:
乘风
2026-02-13 16:36:38 +08:00
parent 76624605a5
commit 35b669c31e
13 changed files with 469 additions and 187 deletions

View File

@@ -250,15 +250,18 @@ Page({
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
// 始终用接口返回的 price/isFree 更新 section不写死 1 元)
const section = this.data.section || {}
if (res.price !== undefined && res.price !== null) section.price = Number(res.price)
if (res.isFree !== undefined) section.isFree = !!res.isFree
// 0元即免费接口返回 price 为 0 或 isFree 为 true 时,不展示付费墙
const isFreeByPrice = res.price === 0 || res.price === '0' || Number(res.price) === 0
const isFreeByFlag = res.isFree === true
if (isFreeByPrice || isFreeByFlag) {
const section = this.data.section || {}
if (res.price !== undefined && res.price !== null) section.price = Number(res.price)
if (res.isFree !== undefined) section.isFree = !!res.isFree
this.setData({ section, showPaywall: false, canAccess: true, accessState: 'free' })
app.markSectionAsRead(id)
} else {
this.setData({ section })
}
setTimeout(() => this.drawShareCard(), 600)
}
@@ -304,12 +307,12 @@ Page({
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
}
// 普通章节
// 普通章节price 不写死,由 loadContent 从 config/接口 填充
return {
id: id,
title: this.getSectionTitle(id),
isFree: id === '1.1',
price: 1
price: undefined
}
},
@@ -710,7 +713,7 @@ Page({
return
}
const price = this.data.section?.price || 1
const price = this.data.section?.price ?? this.data.sectionPrice ?? 1
console.log('[Pay] 开始支付流程:', { sectionId: this.data.sectionId, price })
wx.hideLoading()
await this.processPayment('section', this.data.sectionId, price)

View File

@@ -63,67 +63,8 @@ Page({
}
},
// 一键获取收货地址
getAddress() {
wx.chooseAddress({
success: (res) => {
console.log('[Settings] 获取地址成功:', res)
const fullAddress = `${res.provinceName || ''}${res.cityName || ''}${res.countyName || ''}${res.detailInfo || ''}`
if (fullAddress.trim()) {
wx.setStorageSync('user_address', fullAddress)
this.setData({ address: fullAddress })
// 更新用户信息
if (app.globalData.userInfo) {
app.globalData.userInfo.address = fullAddress
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
// 同步到服务器
this.syncAddressToServer(fullAddress)
wx.showToast({ title: '地址已获取', icon: 'success' })
}
},
fail: (e) => {
console.log('[Settings] 获取地址失败:', e)
if (e.errMsg?.includes('cancel')) {
// 用户取消,不提示
return
}
if (e.errMsg?.includes('auth deny') || e.errMsg?.includes('authorize')) {
wx.showModal({
title: '需要授权',
content: '请在设置中允许获取收货地址',
confirmText: '去设置',
success: (res) => {
if (res.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: '获取失败,请重试', icon: 'none' })
}
}
})
},
// 同步地址到服务器
async syncAddressToServer(address) {
try {
const userId = app.globalData.userInfo?.id
if (!userId) return
await app.request('/api/miniprogram/user/update', {
method: 'POST',
data: { userId, address }
})
console.log('[Settings] 地址已同步到服务器')
} catch (e) {
console.log('[Settings] 同步地址失败:', e)
}
},
// 收货地址已改为「地址管理」页goToAddresses
// 切换自动提现
async toggleAutoWithdraw(e) {
const enabled = e.detail.value

View File

@@ -1,6 +1,6 @@
# 对接后端 base URL不改 API 路径,仅改此处即可切换 Next → Gin
# 宝塔部署:若 API 站点开启了强制 HTTPS这里必须用 https否则预检请求会被重定向导致 CORS 报错
# VITE_API_BASE_URL=http://localhost:3006
# VITE_API_BASE_URL=http://localhost:8080
VITE_API_BASE_URL=https://souldev.quwanzhi.com
VITE_API_BASE_URL=http://localhost:8080
# VITE_API_BASE_URL=https://souldev.quwanzhi.com

View File

@@ -289,8 +289,7 @@ export function DashboardPage() {
<CardContent>
<div className="space-y-3">
{users
.slice(-5)
.reverse()
.slice(0, 5)
.map((u) => (
<div
key={u.id}

View File

@@ -73,6 +73,7 @@ interface User {
id: string
nickname: string
phone: string
avatar?: string | null
referralCode?: string
}
@@ -81,6 +82,7 @@ interface Order {
userId: string
userNickname?: string
userPhone?: string
userAvatar?: string | null
productType?: string
type?: string
productId?: string
@@ -122,6 +124,7 @@ export function DistributionPage() {
}, [activeTab])
async function loadInitialData() {
setLoading(true)
try {
const overviewData = await get<{ success?: boolean; overview?: DistributionOverview }>(
'/api/admin/distribution/overview',
@@ -135,6 +138,8 @@ export function DistributionPage() {
setUsers(usersData?.users || [])
} catch (e) {
console.error('[Admin] 用户数据加载失败:', e)
} finally {
setLoading(false)
}
}
@@ -150,21 +155,16 @@ export function DistributionPage() {
try {
const ordersData = await get<{ success?: boolean; orders?: Order[] }>('/api/orders')
if (ordersData?.success && ordersData.orders) {
const enriched = ordersData.orders.map((order) => {
const user = usersArr.find((u) => u.id === order.userId)
const referrer = order.referrerId
? usersArr.find((u) => u.id === order.referrerId)
: null
return {
...order,
amount: parseFloat(String(order.amount)) || 0,
userNickname: user?.nickname || order.userNickname || '未知用户',
userPhone: user?.phone || order.userPhone || '-',
referrerNickname: referrer?.nickname || null,
referrerCode: referrer?.referralCode ?? null,
type: order.productType || order.type,
}
})
const enriched = ordersData.orders.map((order) => ({
...order,
amount: parseFloat(String(order.amount)) || 0,
userNickname: order.userNickname ?? usersArr.find((u) => u.id === order.userId)?.nickname ?? '未知用户',
userPhone: order.userPhone || usersArr.find((u) => u.id === order.userId)?.phone || '-',
userAvatar: order.userAvatar ?? usersArr.find((u) => u.id === order.userId)?.avatar ?? null,
referrerNickname: order.referrerNickname ?? (order.referrerId ? usersArr.find((u) => u.id === order.referrerId)?.nickname : null) ?? null,
referrerCode: order.referrerCode ?? (order.referrerId ? usersArr.find((u) => u.id === order.referrerId)?.referralCode : null) ?? null,
type: order.productType || order.type,
}))
setOrders(enriched)
} else setOrders([])
} catch {
@@ -217,7 +217,8 @@ export function DistributionPage() {
} catch (e) {
console.error(e)
} finally {
setLoading(false)
// 概览数据由 loadInitialData 控制 loading避免一进页就被这里立刻关掉
if (tab !== 'overview') setLoading(false)
}
}
@@ -647,9 +648,18 @@ export function DistributionPage() {
{order.id?.slice(0, 12)}...
</td>
<td className="p-4">
<div>
<p className="text-white text-sm">{order.userNickname}</p>
<p className="text-gray-500 text-xs">{order.userPhone}</p>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac] overflow-hidden shrink-0">
{order.userAvatar ? (
<img src={order.userAvatar} className="w-full h-full object-cover" alt="" />
) : (
(order.userNickname || '?').charAt(0)
)}
</div>
<div>
<p className="text-white text-sm">{order.userNickname || '未知用户'}</p>
<p className="text-gray-500 text-xs">{order.userPhone || '-'}</p>
</div>
</div>
</td>
<td className="p-4">

View File

@@ -22,7 +22,6 @@ import {
import { Switch } from '@/components/ui/switch'
import {
Search,
UserPlus,
Trash2,
Edit3,
Key,
@@ -31,6 +30,8 @@ import {
RefreshCw,
Users,
Eye,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { get, del, post, put } from '@/api/client'
@@ -49,6 +50,7 @@ interface User {
pendingEarnings?: number | string
withdrawnEarnings?: number | string
referralCount?: number
purchasedSectionCount?: number
createdAt: string
updatedAt?: string | null
}
@@ -56,6 +58,10 @@ interface User {
export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [page, setPage] = useState(1)
const [pageSize] = useState(15)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [, setError] = useState<string | null>(null)
const [showUserModal, setShowUserModal] = useState(false)
@@ -81,13 +87,29 @@ export function UsersPage() {
hasFullBook: false,
})
async function loadUsers() {
async function loadUsers(overridePage?: number) {
setIsLoading(true)
setError(null)
try {
const data = await get<{ success?: boolean; users?: User[]; error?: string }>('/api/db/users')
if (data?.success) setUsers(data.users || [])
else setError(data?.error || '加载失败')
const params = new URLSearchParams()
params.set('page', String(overridePage ?? page))
params.set('pageSize', String(pageSize))
if (searchTerm.trim()) params.set('search', searchTerm.trim())
const data = await get<{
success?: boolean
users?: User[]
total?: number
page?: number
pageSize?: number
totalPages?: number
error?: string
}>(`/api/db/users?${params.toString()}`)
if (data?.success) {
setUsers(data.users || [])
setTotal(data.total ?? 0)
setTotalPages(data.totalPages ?? 0)
if (overridePage != null) setPage(overridePage)
} else setError(data?.error || '加载失败')
} catch (err) {
console.error('Load users error:', err)
setError('网络错误,请检查连接')
@@ -98,13 +120,11 @@ export function UsersPage() {
useEffect(() => {
loadUsers()
}, [])
}, [page])
const filteredUsers = users.filter(
(u) =>
(u.nickname || '').includes(searchTerm) ||
(u.phone || '').includes(searchTerm),
)
const handleSearch = () => {
loadUsers(1)
}
async function handleDelete(userId: string) {
if (!confirm('确定要删除这个用户吗?')) return
@@ -132,48 +152,24 @@ export function UsersPage() {
setShowUserModal(true)
}
const handleAddUser = () => {
setEditingUser(null)
setFormData({
phone: '',
nickname: '',
password: '',
isAdmin: false,
hasFullBook: false,
})
setShowUserModal(true)
}
async function handleSaveUser() {
if (!editingUser) return
if (!formData.phone || !formData.nickname) {
alert('请填写手机号和昵称')
return
}
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: editingUser.id,
nickname: formData.nickname,
isAdmin: formData.isAdmin,
hasFullBook: formData.hasFullBook,
...(formData.password && { password: formData.password }),
})
if (!data?.success) {
alert('更新失败: ' + (data?.error || '未知错误'))
return
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', {
phone: formData.phone,
nickname: formData.nickname,
password: formData.password,
isAdmin: formData.isAdmin,
})
if (!data?.success) {
alert('创建失败: ' + (data?.error || '未知错误'))
return
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: editingUser.id,
nickname: formData.nickname,
isAdmin: formData.isAdmin,
hasFullBook: formData.hasFullBook,
...(formData.password && { password: formData.password }),
})
if (!data?.success) {
alert('更新失败: ' + (data?.error || '未知错误'))
return
}
setShowUserModal(false)
loadUsers()
@@ -253,32 +249,39 @@ export function UsersPage() {
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1"> {users.length} </p>
<p className="text-gray-400 mt-1"> {total} </p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={loadUsers}
onClick={() => loadUsers()}
disabled={isLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
type="text"
placeholder="搜索用户..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
<Input
type="text"
placeholder="搜索昵称/手机/ID..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-56"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button
variant="outline"
onClick={handleSearch}
disabled={isLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
</Button>
</div>
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
@@ -286,12 +289,8 @@ export function UsersPage() {
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingUser ? (
<Edit3 className="w-5 h-5 text-[#38bdac]" />
) : (
<UserPlus className="w-5 h-5 text-[#38bdac]" />
)}
{editingUser ? '编辑用户' : '添加用户'}
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -469,6 +468,7 @@ export function UsersPage() {
const r = ref as {
id?: string
nickname?: string
avatar?: string | null
phone?: string
hasOpenId?: boolean
status?: string
@@ -478,8 +478,12 @@ export function UsersPage() {
return (
<div key={r.id || i} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{r.nickname?.charAt(0) || '?'}
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac] overflow-hidden shrink-0">
{r.avatar ? (
<img src={r.avatar} className="w-full h-full object-cover" alt="" />
) : (
r.nickname?.charAt(0) || '?'
)}
</div>
<div>
<div className="text-white text-sm">{r.nickname}</div>
@@ -545,7 +549,7 @@ export function UsersPage() {
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
{users.map((user) => (
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
@@ -608,6 +612,10 @@ export function UsersPage() {
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : (user.purchasedSectionCount ?? 0) > 0 ? (
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
{(user.purchasedSectionCount ?? 0)}
</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600">
@@ -686,16 +694,45 @@ export function UsersPage() {
</TableCell>
</TableRow>
))}
{filteredUsers.length === 0 && (
{users.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
{searchTerm ? '未找到匹配用户' : '暂无用户数据'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
{!isLoading && totalPages > 0 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-700/50">
<span className="text-sm text-gray-400">
{page} / {totalPages} {total}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -27,6 +27,9 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.MatchRecord{}); err != nil {
log.Printf("database: match_records migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserAddress{}); err != nil {
log.Printf("database: user_addresses migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -78,6 +78,10 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
}
if ch.Price != nil {
out["price"] = *ch.Price
// 价格为 0 元则自动视为免费
if *ch.Price == 0 {
out["isFree"] = true
}
}
c.JSON(http.StatusOK, out)
}

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/database"
@@ -336,16 +338,162 @@ func DBConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}
// DBUsersList GET /api/db/users
// DBUsersList GET /api/db/users(支持分页 page、pageSize可选搜索 search购买状态、分销收益、绑定人数从订单/绑定表实时计算)
func DBUsersList(c *gin.Context) {
db := database.DB()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "15"))
search := strings.TrimSpace(c.DefaultQuery("search", ""))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 15
}
q := db.Model(&model.User{})
if search != "" {
pattern := "%" + search + "%"
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
var total int64
q.Count(&total)
var users []model.User
if err := database.DB().Find(&users).Error; err != nil {
if err := q.Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&users).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "users": users})
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
if len(users) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true, "users": users,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
return
}
// 读取推广配置中的分销比例
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
userIDs := make([]string, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
// 1. 购买状态:全书已购、已付费章节数(从 orders 计算)
hasFullBookMap := make(map[string]bool)
sectionCountMap := make(map[string]int)
var fullbookRows []struct {
UserID string
}
db.Model(&model.Order{}).Select("user_id").Where("product_type = ? AND status = ?", "fullbook", "paid").Find(&fullbookRows)
for _, r := range fullbookRows {
hasFullBookMap[r.UserID] = true
}
var sectionRows []struct {
UserID string
Count int64
}
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
Where("product_type = ? AND status = ?", "section", "paid").
Group("user_id").Find(&sectionRows)
for _, r := range sectionRows {
sectionCountMap[r.UserID] = int(r.Count)
}
// 2. 分销收益:从 referrer 订单计算佣金;可提现 = 累计佣金 - 已提现 - 待处理提现
referrerEarningsMap := make(map[string]float64)
var referrerRows []struct {
ReferrerID string
Total float64
}
db.Model(&model.Order{}).Select("referrer_id, COALESCE(SUM(amount), 0) as total").
Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").
Group("referrer_id").Find(&referrerRows)
for _, r := range referrerRows {
referrerEarningsMap[r.ReferrerID] = r.Total * distributorShare
}
withdrawnMap := make(map[string]float64)
var withdrawnRows []struct {
UserID string
Total float64
}
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
Where("status = ?", "success").
Group("user_id").Find(&withdrawnRows)
for _, r := range withdrawnRows {
withdrawnMap[r.UserID] = r.Total
}
pendingWithdrawMap := make(map[string]float64)
var pendingRows []struct {
UserID string
Total float64
}
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
Where("status IN ?", []string{"pending", "processing", "pending_confirm"}).
Group("user_id").Find(&pendingRows)
for _, r := range pendingRows {
pendingWithdrawMap[r.UserID] = r.Total
}
// 3. 绑定人数:从 referral_bindings 计算
referralCountMap := make(map[string]int)
var refCountRows []struct {
ReferrerID string
Count int64
}
db.Model(&model.ReferralBinding{}).Select("referrer_id, COUNT(*) as count").
Group("referrer_id").Find(&refCountRows)
for _, r := range refCountRows {
referralCountMap[r.ReferrerID] = int(r.Count)
}
// 填充每个用户的实时计算字段
for i := range users {
uid := users[i].ID
// 购买状态
users[i].HasFullBook = ptrBool(hasFullBookMap[uid])
users[i].PurchasedSectionCount = sectionCountMap[uid]
// 分销收益
totalE := referrerEarningsMap[uid]
withdrawn := withdrawnMap[uid]
pendingWd := pendingWithdrawMap[uid]
available := totalE - withdrawn - pendingWd
if available < 0 {
available = 0
}
users[i].Earnings = ptrFloat64(totalE)
users[i].PendingEarnings = ptrFloat64(available)
users[i].ReferralCount = ptrInt(referralCountMap[uid])
}
c.JSON(http.StatusOK, gin.H{
"success": true, "users": users,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
}
func ptrBool(b bool) *bool { return &b }
func ptrFloat64(f float64) *float64 { v := f; return &v }
func ptrInt(n int) *int { return &n }
// DBUsersAction POST /api/db/users创建、PUT /api/db/users更新
func DBUsersAction(c *gin.Context) {
db := database.DB()
@@ -454,7 +602,7 @@ func DBUsersDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
}
// DBUsersReferrals GET /api/db/users/referrals
// DBUsersReferrals GET /api/db/users/referrals(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算)
func DBUsersReferrals(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
@@ -462,6 +610,19 @@ func DBUsersReferrals(c *gin.Context) {
return
}
db := database.DB()
// 分销比例(与小程序 /api/miniprogram/earnings、支付回调一致
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
var bindings []model.ReferralBinding
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "referrals": []interface{}{}, "stats": gin.H{"total": 0, "purchased": 0, "free": 0, "earnings": 0, "pendingEarnings": 0, "withdrawnEarnings": 0}})
@@ -503,42 +664,82 @@ func DBUsersReferrals(c *gin.Context) {
if b.ExpiryDate.After(time.Now()) {
daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
}
// 已付费:与小程序一致,以绑定记录的 purchase_count > 0 为准(支付回调会更新该字段)
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free供前端徽章展示
referrals = append(referrals, gin.H{
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
"hasFullBook": hasFullBook || status == "converted",
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.CommissionAmount,
"status": status,
"purchasedSections": getBindingPurchaseCount(b),
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
"status": displayStatus,
})
}
var referrer model.User
earningsE, pendingE, withdrawnE := 0.0, 0.0, 0.0
if err := db.Where("id = ?", userId).Select("earnings", "pending_earnings", "withdrawn_earnings").First(&referrer).Error; err == nil {
if referrer.Earnings != nil {
earningsE = *referrer.Earnings
}
if referrer.PendingEarnings != nil {
pendingE = *referrer.PendingEarnings
}
if referrer.WithdrawnEarnings != nil {
withdrawnE = *referrer.WithdrawnEarnings
}
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单+提现表实时计算
var orderSum struct{ Total float64 }
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0) as total").
Where("referrer_id = ? AND status = ?", userId, "paid").
Scan(&orderSum)
earningsE := orderSum.Total * distributorShare
var withdrawnSum struct{ Total float64 }
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = ?", userId, "success").
Scan(&withdrawnSum)
withdrawnE := withdrawnSum.Total
var pendingWdSum struct{ Total float64 }
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
Scan(&pendingWdSum)
availableE := earningsE - withdrawnE - pendingWdSum.Total
if availableE < 0 {
availableE = 0
}
// 已付费人数:与小程序一致,绑定中 purchase_count > 0 的条数
purchased := 0
for _, b := range bindings {
u := userMap[b.RefereeID]
if (u != nil && u.HasFullBook != nil && *u.HasFullBook) || (b.Status != nil && *b.Status == "converted") {
if b.PurchaseCount != nil && *b.PurchaseCount > 0 {
purchased++
}
}
c.JSON(http.StatusOK, gin.H{
"success": true, "referrals": referrals,
"stats": gin.H{
"total": len(bindings), "purchased": purchased, "free": len(bindings) - purchased,
"earnings": earningsE, "pendingEarnings": pendingE, "withdrawnEarnings": withdrawnE,
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),
},
})
}
func getBindingPurchaseCount(b model.ReferralBinding) int {
if b.PurchaseCount == nil {
return 0
}
return *b.PurchaseCount
}
func bindingStatusDisplay(hasPaid bool, hasFullBook bool) string {
if hasFullBook {
return "vip"
}
if hasPaid {
return "paid"
}
return "free"
}
func roundFloat(v float64, prec int) float64 {
ratio := 1.0
for i := 0; i < prec; i++ {
ratio *= 10
}
return float64(int(v*ratio+0.5)) / ratio
}
// DBInit POST /api/db/init
func DBInit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"soul-api/internal/database"
@@ -9,12 +10,92 @@ import (
"github.com/gin-gonic/gin"
)
// OrdersList GET /api/orders
// OrdersList GET /api/orders(带用户昵称/头像/手机号,分销佣金按配置比例计算)
func OrdersList(c *gin.Context) {
db := database.DB()
var orders []model.Order
if err := database.DB().Order("created_at DESC").Find(&orders).Error; err != nil {
if err := db.Order("created_at DESC").Find(&orders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "orders": orders})
if len(orders) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "orders": []interface{}{}})
return
}
// 分销比例(与支付回调一致)
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
// 收集订单中的 user_id、referrer_id查用户信息
userIDs := make(map[string]bool)
for _, o := range orders {
if o.UserID != "" {
userIDs[o.UserID] = true
}
if o.ReferrerID != nil && *o.ReferrerID != "" {
userIDs[*o.ReferrerID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
if len(ids) > 0 {
db.Where("id IN ?", ids).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
getStr := func(s *string) string {
if s == nil || *s == "" {
return ""
}
return *s
}
out := make([]gin.H, 0, len(orders))
for _, o := range orders {
// 序列化订单为基础字段
b, _ := json.Marshal(o)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
// 用户信息
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = getStr(u.Nickname)
m["userPhone"] = getStr(u.Phone)
m["userAvatar"] = getStr(u.Avatar)
} else {
m["userNickname"] = ""
m["userPhone"] = ""
m["userAvatar"] = ""
}
// 推荐人信息
if o.ReferrerID != nil && *o.ReferrerID != "" {
if u := userMap[*o.ReferrerID]; u != nil {
m["referrerNickname"] = getStr(u.Nickname)
m["referrerCode"] = getStr(u.ReferralCode)
}
}
// 分销佣金:仅对已支付且存在推荐人的订单,按配置比例计算(与支付回调口径一致)
status := getStr(o.Status)
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
m["referrerEarnings"] = o.Amount * distributorShare
} else {
m["referrerEarnings"] = nil
}
out = append(out, m)
}
c.JSON(http.StatusOK, gin.H{"success": true, "orders": out})
}

View File

@@ -85,7 +85,7 @@ func UserAddressesByID(c *gin.Context) {
return
}
full := r.Province + r.City + r.District + r.Detail
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,

View File

@@ -22,6 +22,9 @@ type User struct {
IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"`
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
}
func (User) TableName() string { return "users" }

Binary file not shown.