优化小程序阅读页面逻辑,动态更新章节价格和免费状态,确保用户体验流畅。移除硬编码价格,支持通过接口返回的值进行展示。更新用户地址管理逻辑,简化获取地址的流程,提升代码可读性。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(§ionRows)
|
||||
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": "初始化接口已就绪(表结构由迁移维护)"}})
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user