diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 971dbb8c..2deae32b 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -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) diff --git a/miniprogram/pages/settings/settings.js b/miniprogram/pages/settings/settings.js index 230a7582..922390bf 100644 --- a/miniprogram/pages/settings/settings.js +++ b/miniprogram/pages/settings/settings.js @@ -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 diff --git a/soul-admin/.env.development b/soul-admin/.env.development index e808fead..f0c51361 100644 --- a/soul-admin/.env.development +++ b/soul-admin/.env.development @@ -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 diff --git a/soul-admin/src/pages/dashboard/DashboardPage.tsx b/soul-admin/src/pages/dashboard/DashboardPage.tsx index 00ac08d1..3fce5f08 100644 --- a/soul-admin/src/pages/dashboard/DashboardPage.tsx +++ b/soul-admin/src/pages/dashboard/DashboardPage.tsx @@ -289,8 +289,7 @@ export function DashboardPage() {
{users - .slice(-5) - .reverse() + .slice(0, 5) .map((u) => (
( '/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)}... -
-

{order.userNickname}

-

{order.userPhone}

+
+
+ {order.userAvatar ? ( + + ) : ( + (order.userNickname || '?').charAt(0) + )} +
+
+

{order.userNickname || '未知用户'}

+

{order.userPhone || '-'}

+
diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 05492ef5..4dd35e62 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -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([]) 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(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() {

用户管理

-

共 {users.length} 位注册用户

+

共 {total} 位注册用户

-
- - setSearchTerm(e.target.value)} - /> +
+
+ + setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+
-
@@ -286,12 +289,8 @@ export function UsersPage() { - {editingUser ? ( - - ) : ( - - )} - {editingUser ? '编辑用户' : '添加用户'} + + 编辑用户
@@ -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 (
-
- {r.nickname?.charAt(0) || '?'} +
+ {r.avatar ? ( + + ) : ( + r.nickname?.charAt(0) || '?' + )}
{r.nickname}
@@ -545,7 +549,7 @@ export function UsersPage() { - {filteredUsers.map((user) => ( + {users.map((user) => (
@@ -608,6 +612,10 @@ export function UsersPage() { 全书已购 + ) : (user.purchasedSectionCount ?? 0) > 0 ? ( + + 已付费{(user.purchasedSectionCount ?? 0)}章 + ) : ( 未购买 @@ -686,16 +694,45 @@ export function UsersPage() { ))} - {filteredUsers.length === 0 && ( + {users.length === 0 && ( - 暂无用户数据 + {searchTerm ? '未找到匹配用户' : '暂无用户数据'} )} )} + {!isLoading && totalPages > 0 && ( +
+ + 第 {page} / {totalPages} 页,共 {total} 条 + +
+ + +
+
+ )}
diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go index ab0cca29..7c7670cd 100644 --- a/soul-api/internal/database/database.go +++ b/soul-api/internal/database/database.go @@ -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 } diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go index 9aacea05..007722d4 100644 --- a/soul-api/internal/handler/book.go +++ b/soul-api/internal/handler/book.go @@ -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) } diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index 3f2ef374..b7b2115d 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -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": "初始化接口已就绪(表结构由迁移维护)"}}) diff --git a/soul-api/internal/handler/orders.go b/soul-api/internal/handler/orders.go index 25326b3d..06c30715 100644 --- a/soul-api/internal/handler/orders.go +++ b/soul-api/internal/handler/orders.go @@ -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}) } diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go index 1084c75b..de93bce7 100644 --- a/soul-api/internal/handler/user.go +++ b/soul-api/internal/handler/user.go @@ -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, diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go index fdf11de2..6f85f34d 100644 --- a/soul-api/internal/model/user.go +++ b/soul-api/internal/model/user.go @@ -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" } diff --git a/soul-api/soul-api b/soul-api/soul-api index ed62f924..22702036 100644 Binary files a/soul-api/soul-api and b/soul-api/soul-api differ