更新管理后台布局,优化菜单项标签,新增支付配置项。同时,调整API响应字段命名,确保一致性,提升代码可读性和维护性。

This commit is contained in:
乘风
2026-02-09 14:33:41 +08:00
parent bee72dc7f8
commit dfbe3eb427
77 changed files with 3041 additions and 240 deletions

View File

@@ -4,7 +4,7 @@ import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen } from "lucide-react"
import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen, Banknote } from "lucide-react"
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
@@ -43,11 +43,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
// 简化菜单:按功能归类,保留核心功能
// PDF需求分账管理、分销管理、订单管理三合一 → 交易中心
const menuItems = [
{ icon: LayoutDashboard, label: "数据概览1888", href: "/admin" },
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" },
{ icon: BookOpen, label: "内容管理", href: "/admin/content" },
{ icon: Users, label: "用户管理", href: "/admin/users" },
{ icon: Wallet, label: "交易中心", href: "/admin/distribution" }, // 合并:分销+订单+提现
{ icon: CreditCard, label: "推广设置", href: "/admin/referral-settings" }, // 单独入口,集中管理分销配置
{ icon: CreditCard, label: "推广设置", href: "/admin/referral-settings" },
{ icon: Banknote, label: "支付配置", href: "/admin/payment" },
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
]

View File

@@ -32,15 +32,15 @@ export async function GET(request: NextRequest) {
id: w.id,
amount: Number(w.amount),
package: w.package_info ?? '',
created_at: w.created_at,
createdAt: w.created_at,
}))
return NextResponse.json({
success: true,
data: {
list: items,
mch_id: mchId,
app_id: appId,
mchId,
appId,
},
})
} catch (e) {

View File

@@ -27,8 +27,8 @@ export async function GET(request: NextRequest) {
id: r.id,
amount: Number(r.amount),
status: r.status,
created_at: r.created_at,
processed_at: r.processed_at,
createdAt: r.created_at,
processedAt: r.processed_at,
}))
return NextResponse.json({

View File

@@ -157,12 +157,12 @@ Page({
id: item.id,
amount: (item.amount || 0).toFixed(2),
package: item.package,
created_at: item.created_at ? this.formatDateMy(item.created_at) : '--'
createdAt: (item.createdAt ?? item.created_at) ? this.formatDateMy(item.createdAt ?? item.created_at) : '--'
}))
this.setData({
pendingConfirmList: list,
withdrawMchId: res.data.mch_id || '',
withdrawAppId: res.data.app_id || ''
withdrawMchId: res.data.mchId ?? res.data.mch_id ?? '',
withdrawAppId: res.data.appId ?? res.data.app_id ?? ''
})
} else {
this.setData({ pendingConfirmList: [], withdrawMchId: '', withdrawAppId: '' })

View File

@@ -120,7 +120,7 @@
<view class="pending-confirm-item" wx:for="{{pendingConfirmList}}" wx:key="id">
<view class="pending-confirm-info">
<text class="pending-confirm-amount">¥{{item.amount}}</text>
<text class="pending-confirm-time">{{item.created_at}}</text>
<text class="pending-confirm-time">{{item.createdAt}}</text>
</view>
<view class="pending-confirm-btn" bindtap="confirmReceive" data-index="{{index}}">确认收款</view>
</view>

View File

@@ -34,7 +34,7 @@ Page({
amount: (item.amount != null ? item.amount : 0).toFixed(2),
status: this.statusText(item.status),
statusRaw: item.status,
created_at: item.created_at ? this.formatDate(item.created_at) : '--'
createdAt: (item.createdAt ?? item.created_at) ? this.formatDate(item.createdAt ?? item.created_at) : '--'
}))
this.setData({ list, loading: false })
} else {

View File

@@ -13,7 +13,7 @@
<view class="item" wx:for="{{list}}" wx:key="id">
<view class="item-left">
<text class="amount">¥{{item.amount}}</text>
<text class="time">{{item.created_at}}</text>
<text class="time">{{item.createdAt}}</text>
</view>
<text class="status status-{{item.statusRaw}}">{{item.status}}</text>
</view>

View File

@@ -1,2 +1,3 @@
# 开发环境:对接当前 Next 后端(与现网 API 路径完全一致,无缝切换
VITE_API_BASE_URL=http://localhost:3006
# 对接后端 base URL不改 API 路径,仅改此处即可切换 Next → Gin
# VITE_API_BASE_URL=http://localhost:3006
VITE_API_BASE_URL=http://localhost:8080

View File

@@ -1,2 +1,3 @@
# 对接后端 base URL不改 API 路径,仅改此处即可切换 Next → Gin
VITE_API_BASE_URL=http://localhost:3006
# VITE_API_BASE_URL=http://localhost:3006
VITE_API_BASE_URL=http://localhost:8080

View File

@@ -0,0 +1,2 @@
# 开发环境:对接当前 Next 后端(与现网 API 路径完全一致,无缝切换)
VITE_API_BASE_URL=http://localhost:8080

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-DX3SXTVU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Z7C0sgIG.css">
<script type="module" crossorigin src="/assets/index-BV6dxvbB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8xDvmbL.css">
</head>
<body>
<div id="root"></div>

View File

@@ -39,20 +39,20 @@ interface UserDetail {
phone?: string
nickname: string
avatar?: string
wechat_id?: string
open_id?: string
referral_code: string
referred_by?: string
has_full_book?: boolean
is_admin?: boolean
wechatId?: string
openId?: string
referralCode?: string
referredBy?: string
hasFullBook?: boolean
isAdmin?: boolean
earnings?: number
pending_earnings?: number
referral_count?: number
created_at?: string
updated_at?: string
pendingEarnings?: number
referralCount?: number
createdAt?: string
updatedAt?: string
tags?: string
ckb_tags?: string
ckb_synced_at?: string
ckbTags?: string
ckbSyncedAt?: string
}
interface UserTrack {
@@ -234,19 +234,19 @@ export function UserDetailModal({
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
{user.is_admin && (
{user.isAdmin && (
<Badge className="bg-purple-500/20 text-purple-400 border-0"></Badge>
)}
{user.has_full_book && (
{user.hasFullBook && (
<Badge className="bg-green-500/20 text-green-400 border-0"></Badge>
)}
</div>
<p className="text-gray-400 text-sm mt-1">
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
{user.wechat_id && ` · 💬 ${user.wechat_id}`}
{user.wechatId && ` · 💬 ${user.wechatId}`}
</p>
<p className="text-gray-500 text-xs mt-1">
ID: {user.id} · 广: {user.referral_code}
ID: {user.id} · 广: {user.referralCode ?? '-'}
</p>
</div>
<div className="text-right">
@@ -295,18 +295,18 @@ export function UserDetailModal({
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white">{user.referral_count || 0}</p>
<p className="text-2xl font-bold text-white">{user.referralCount ?? 0}</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-yellow-400">
¥{(user.pending_earnings || 0).toFixed(2)}
¥{(user.pendingEarnings ?? 0).toFixed(2)}
</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-sm text-white">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
</p>
</div>
</div>
@@ -336,7 +336,7 @@ export function UserDetailModal({
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
{user.ckb_synced_at ? (
{user.ckbSyncedAt ? (
<Badge className="bg-green-500/20 text-green-400 border-0 ml-1"></Badge>
) : (
<Badge className="bg-gray-500/20 text-gray-400 border-0 ml-1"></Badge>
@@ -345,7 +345,7 @@ export function UserDetailModal({
<div>
<span className="text-gray-500"></span>
<span className="text-gray-300 ml-1">
{user.ckb_synced_at ? new Date(user.ckb_synced_at).toLocaleString() : '-'}
{user.ckbSyncedAt ? new Date(user.ckbSyncedAt).toLocaleString() : '-'}
</span>
</div>
</div>

View File

@@ -119,10 +119,10 @@ function buildTree(sections: SectionListItem[]): Part[] {
}))
}
function parseTxtToJson(content: string, fileName: string): { id: string; title: string; price: number; content?: string; is_free?: boolean }[] {
function parseTxtToJson(content: string, fileName: string): { id: string; title: string; price: number; content?: string; isFree?: boolean }[] {
const lines = content.split('\n')
const sections: { id: string; title: string; price: number; content?: string; is_free?: boolean }[] = []
let currentSection: { id: string; title: string; price: number; content?: string; is_free?: boolean } | null = null
const sections: { id: string; title: string; price: number; content?: string; isFree?: boolean }[] = []
let currentSection: { id: string; title: string; price: number; content?: string; isFree?: boolean } | null = null
let currentContent: string[] = []
let sectionIndex = 1
@@ -137,7 +137,7 @@ function parseTxtToJson(content: string, fileName: string): { id: string; title:
id: `import-${sectionIndex}`,
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
price: 1,
is_free: sectionIndex <= 3,
isFree: sectionIndex <= 3,
}
currentContent = []
sectionIndex++
@@ -148,7 +148,7 @@ function parseTxtToJson(content: string, fileName: string): { id: string; title:
id: `import-${sectionIndex}`,
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
price: 1,
is_free: true,
isFree: true,
}
currentContent.push(line)
sectionIndex++

View File

@@ -23,7 +23,7 @@ interface UserRow {
id: string
nickname?: string
phone?: string
referral_code?: string
referralCode?: string
createdAt?: string
}
@@ -201,7 +201,7 @@ export function DashboardPage() {
: undefined
const inviteCode =
p.referralCode ||
referrer?.referral_code ||
referrer?.referralCode ||
referrer?.nickname ||
(p.referrerId ? String(p.referrerId).slice(0, 8) : '')
const product = formatOrderProduct(p)

View File

@@ -42,24 +42,22 @@ interface DistributionOverview {
interface Binding {
id: string
referrer_id: string
referrer_name?: string
referrer_code: string
referee_id: string
referee_phone?: string
referee_nickname?: string
bound_at: string
expires_at: string
referrerId: string
referrerName?: string
referrerCode: string
refereeId: string
refereePhone?: string
refereeNickname?: string
boundAt: string
expiresAt: string
status: 'active' | 'converted' | 'expired' | 'cancelled'
commission?: number
}
interface Withdrawal {
id: string
user_id?: string
userId?: string
user_name?: string
userNickname?: string
userName?: string
userPhone?: string
userAvatar?: string
amount: number
@@ -67,17 +65,15 @@ interface Withdrawal {
account?: string
name?: string
status: string
created_at?: string
createdAt?: string
processedAt?: string
completed_at?: string
}
interface User {
id: string
nickname: string
phone: string
referral_code: string
referralCode?: string
}
interface Order {
@@ -165,7 +161,7 @@ export function DistributionPage() {
userNickname: user?.nickname || order.userNickname || '未知用户',
userPhone: user?.phone || order.userPhone || '-',
referrerNickname: referrer?.nickname || null,
referrerCode: referrer?.referral_code || null,
referrerCode: referrer?.referralCode ?? null,
type: order.productType || order.type,
}
})
@@ -197,9 +193,6 @@ export function DistributionPage() {
if (withdrawalsData?.success && withdrawalsData.withdrawals) {
const formatted = withdrawalsData.withdrawals.map((w) => ({
...w,
user_name: w.userNickname ?? w.user_name,
created_at: w.created_at ?? w.createdAt,
completed_at: w.processedAt ?? w.completed_at,
account: w.account ?? '未绑定微信号',
status:
w.status === 'success' ? 'completed' : w.status === 'failed' ? 'rejected' : w.status,
@@ -294,10 +287,10 @@ export function DistributionPage() {
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
b.referee_nickname?.toLowerCase().includes(term) ||
b.referee_phone?.includes(term) ||
b.referrer_name?.toLowerCase().includes(term) ||
b.referrer_code?.toLowerCase().includes(term)
b.refereeNickname?.toLowerCase().includes(term) ||
b.refereePhone?.includes(term) ||
b.referrerName?.toLowerCase().includes(term) ||
b.referrerCode?.toLowerCase().includes(term)
)
}
return true
@@ -308,7 +301,7 @@ export function DistributionPage() {
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
w.user_name?.toLowerCase().includes(term) || w.account?.toLowerCase().includes(term)
w.userName?.toLowerCase().includes(term) || w.account?.toLowerCase().includes(term)
)
}
return true
@@ -776,27 +769,27 @@ export function DistributionPage() {
<td className="p-4">
<div>
<p className="text-white font-medium">
{binding.referee_nickname || '匿名用户'}
{binding.refereeNickname || '匿名用户'}
</p>
<p className="text-gray-500 text-xs">{binding.referee_phone}</p>
<p className="text-gray-500 text-xs">{binding.refereePhone}</p>
</div>
</td>
<td className="p-4">
<div>
<p className="text-white">{binding.referrer_name || '-'}</p>
<p className="text-white">{binding.referrerName || '-'}</p>
<p className="text-gray-500 text-xs font-mono">
{binding.referrer_code}
{binding.referrerCode}
</p>
</div>
</td>
<td className="p-4 text-gray-400">
{binding.bound_at
? new Date(binding.bound_at).toLocaleDateString('zh-CN')
{binding.boundAt
? new Date(binding.boundAt).toLocaleDateString('zh-CN')
: '-'}
</td>
<td className="p-4 text-gray-400">
{binding.expires_at
? new Date(binding.expires_at).toLocaleDateString('zh-CN')
{binding.expiresAt
? new Date(binding.expiresAt).toLocaleDateString('zh-CN')
: '-'}
</td>
<td className="p-4">{getStatusBadge(binding.status)}</td>
@@ -874,11 +867,11 @@ export function DistributionPage() {
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center text-white text-sm font-medium">
{(withdrawal.user_name || withdrawal.name || '?').slice(0, 1)}
{(withdrawal.userName || withdrawal.name || '?').slice(0, 1)}
</div>
)}
<p className="text-white font-medium">
{withdrawal.user_name || withdrawal.name}
{withdrawal.userName || withdrawal.name}
</p>
</div>
</td>
@@ -907,10 +900,8 @@ export function DistributionPage() {
</div>
</td>
<td className="p-4 text-gray-400">
{(withdrawal.created_at || withdrawal.createdAt)
? new Date(
withdrawal.created_at || withdrawal.createdAt || '',
).toLocaleString('zh-CN')
{withdrawal.createdAt
? new Date(withdrawal.createdAt).toLocaleString('zh-CN')
: '-'}
</td>
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>

View File

@@ -60,7 +60,7 @@ export function ReferralSettingsPage() {
}
const body = {
key: 'referral_config',
config: safeConfig,
value: safeConfig,
description: '分销 / 推广规则配置',
}
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', body)

View File

@@ -115,13 +115,13 @@ function mergeFromConfigList(list: unknown[]): ReturnType<typeof parseConfigResp
const out: ReturnType<typeof parseConfigResponse> = {}
for (const item of list) {
if (!item || typeof item !== 'object') continue
const row = item as { config_key?: string; config_value?: string }
const key = row.config_key
const row = item as { configKey?: string; configValue?: string }
const key = row.configKey
let val: unknown
try {
val = typeof row.config_value === 'string' ? JSON.parse(row.config_value) : row.config_value
val = typeof row.configValue === 'string' ? JSON.parse(row.configValue) : row.configValue
} catch {
val = row.config_value
val = row.configValue
}
if (key === 'feature_config' && val && typeof val === 'object') out.features = val as Partial<FeatureConfig>
if (key === 'mp_config' && val && typeof val === 'object') out.mpConfig = val as Partial<MpConfig>
@@ -205,7 +205,6 @@ export function SettingsPage() {
const handleSave = async () => {
setIsSaving(true)
try {
await post('/api/db/settings', localSettings).catch(() => {})
await post('/api/db/config', {
key: 'free_chapters',
value: freeChapters,

View File

@@ -37,20 +37,20 @@ import { get, del, post, put } from '@/api/client'
interface User {
id: string
open_id?: string | null
openId?: string | null
phone?: string | null
nickname: string
wechat_id?: string | null
wechatId?: string | null
avatar?: string | null
is_admin?: boolean | number
has_full_book?: boolean | number
referral_code: string
isAdmin?: boolean | number
hasFullBook?: boolean | number
referralCode?: string
earnings: number | string
pending_earnings?: number | string
withdrawn_earnings?: number | string
referral_count: number
created_at: string
updated_at?: string | null
pendingEarnings?: number | string
withdrawnEarnings?: number | string
referralCount?: number
createdAt: string
updatedAt?: string | null
}
export function UsersPage() {
@@ -77,8 +77,8 @@ export function UsersPage() {
phone: '',
nickname: '',
password: '',
is_admin: false,
has_full_book: false,
isAdmin: false,
hasFullBook: false,
})
async function loadUsers() {
@@ -126,8 +126,8 @@ export function UsersPage() {
phone: user.phone || '',
nickname: user.nickname || '',
password: '',
is_admin: !!(user.is_admin ?? false),
has_full_book: !!(user.has_full_book ?? false),
isAdmin: !!(user.isAdmin ?? false),
hasFullBook: !!(user.hasFullBook ?? false),
})
setShowUserModal(true)
}
@@ -138,8 +138,8 @@ export function UsersPage() {
phone: '',
nickname: '',
password: '',
is_admin: false,
has_full_book: false,
isAdmin: false,
hasFullBook: false,
})
setShowUserModal(true)
}
@@ -155,8 +155,8 @@ export function UsersPage() {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: editingUser.id,
nickname: formData.nickname,
is_admin: formData.is_admin,
has_full_book: formData.has_full_book,
isAdmin: formData.isAdmin,
hasFullBook: formData.hasFullBook,
...(formData.password && { password: formData.password }),
})
if (!data?.success) {
@@ -168,7 +168,7 @@ export function UsersPage() {
phone: formData.phone,
nickname: formData.nickname,
password: formData.password,
is_admin: formData.is_admin,
isAdmin: formData.isAdmin,
})
if (!data?.success) {
alert('创建失败: ' + (data?.error || '未知错误'))
@@ -329,15 +329,15 @@ export function UsersPage() {
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.is_admin}
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
checked={formData.isAdmin}
onCheckedChange={(checked) => setFormData({ ...formData, isAdmin: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.has_full_book}
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
checked={formData.hasFullBook}
onCheckedChange={(checked) => setFormData({ ...formData, hasFullBook: checked })}
/>
</div>
</div>
@@ -559,19 +559,19 @@ export function UsersPage() {
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-white">{user.nickname}</p>
{user.is_admin && (
{user.isAdmin && (
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
</Badge>
)}
{user.open_id && !user.id?.startsWith('user_') && (
{user.openId && !user.id?.startsWith('user_') && (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500 font-mono">
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
{user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
</p>
</div>
</div>
@@ -584,27 +584,27 @@ export function UsersPage() {
<span className="text-gray-300">{user.phone}</span>
</div>
)}
{user.wechat_id && (
{user.wechatId && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">💬</span>
<span className="text-gray-300">{user.wechat_id}</span>
<span className="text-gray-300">{user.wechatId}</span>
</div>
)}
{user.open_id && (
{user.openId && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">🔗</span>
<span className="text-gray-500 truncate max-w-[100px]" title={user.open_id}>
{user.open_id.slice(0, 12)}...
<span className="text-gray-500 truncate max-w-[100px]" title={user.openId}>
{user.openId.slice(0, 12)}...
</span>
</div>
)}
{!user.phone && !user.wechat_id && !user.open_id && (
{!user.phone && !user.wechatId && !user.openId && (
<span className="text-gray-600 text-xs"></span>
)}
</div>
</TableCell>
<TableCell>
{user.has_full_book ? (
{user.hasFullBook ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
@@ -619,9 +619,9 @@ export function UsersPage() {
<div className="text-white font-medium">
¥{parseFloat(String(user.earnings || 0)).toFixed(2)}
</div>
{parseFloat(String(user.pending_earnings || 0)) > 0 && (
{parseFloat(String(user.pendingEarnings || 0)) > 0 && (
<div className="text-xs text-yellow-400">
: ¥{parseFloat(String(user.pending_earnings || 0)).toFixed(2)}
: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)}
</div>
)}
<div
@@ -632,17 +632,17 @@ export function UsersPage() {
tabIndex={0}
>
<Users className="w-3 h-3" />
{user.referral_count || 0}
{user.referralCount || 0}
</div>
</div>
</TableCell>
<TableCell>
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
{user.referral_code || '-'}
{user.referralCode || '-'}
</code>
</TableCell>
<TableCell className="text-gray-400">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">

View File

@@ -7,10 +7,8 @@ import { get, put } from '@/api/client'
interface Withdrawal {
id: string
user_id?: string
userId?: string
userNickname?: string
user_name?: string
userName?: string
userPhone?: string
userAvatar?: string
referralCode?: string
@@ -20,9 +18,7 @@ interface Withdrawal {
transactionId?: string
errorMessage?: string
createdAt?: string
created_at?: string
processedAt?: string
completed_at?: string
method?: 'wechat' | 'alipay'
account?: string
name?: string
@@ -66,11 +62,7 @@ export function WithdrawalsPage() {
stats?: Partial<Stats>
}>(`/api/admin/withdrawals?status=${filter}`)
if (data?.success) {
const list = (data.withdrawals || []).map((w) => ({
...w,
createdAt: w.created_at ?? w.createdAt,
userNickname: w.user_name ?? w.userNickname,
}))
const list = data.withdrawals || []
setWithdrawals(list)
setStats({
total: data.stats?.total ?? list.length,
@@ -130,7 +122,7 @@ export function WithdrawalsPage() {
try {
const data = await put<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals',
{ id, action: 'reject', reason },
{ id, action: 'reject', errorMessage: reason },
)
if (data?.success) loadWithdrawals()
else alert('操作失败: ' + (data?.error ?? ''))
@@ -310,27 +302,27 @@ export function WithdrawalsPage() {
{withdrawals.map((w) => (
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 text-gray-400">
{new Date(w.created_at ?? w.createdAt ?? '').toLocaleString()}
{new Date(w.createdAt ?? '').toLocaleString()}
</td>
<td className="p-4">
<div className="flex items-center gap-2">
{w.userAvatar ? (
<img
src={w.userAvatar}
alt={w.userNickname ?? w.user_name ?? ''}
alt={w.userName ?? ''}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{(w.userNickname ?? w.user_name ?? '?').charAt(0)}
{(w.userName ?? '?').charAt(0)}
</div>
)}
<div>
<p className="font-medium text-white">
{w.userNickname ?? w.user_name ?? '未知'}
{w.userName ?? '未知'}
</p>
<p className="text-xs text-gray-500">
{w.userPhone ?? w.referralCode ?? (w.user_id ?? w.userId ?? '').slice(0, 10)}
{w.userPhone ?? w.referralCode ?? (w.userId ?? '').slice(0, 10)}
</p>
</div>
</div>
@@ -385,9 +377,7 @@ export function WithdrawalsPage() {
)}
</td>
<td className="p-4 text-gray-400">
{(w.processedAt ?? w.completed_at)
? new Date(w.processedAt ?? w.completed_at ?? '').toLocaleString()
: '-'}
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
</td>
<td className="p-4 text-right">
{(w.status === 'pending' || w.status === 'pending_confirm') && (

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}

23
soul-api/.air.toml Normal file
View File

@@ -0,0 +1,23 @@
# Air 热重载配置:改 .go 后自动重新编译并重启
root = "."
tmp_dir = "tmp"
# Windows 下用 .exe 避免系统弹出「选择应用打开 main」
[build]
bin = "./tmp/main.exe"
cmd = "go build -o ./tmp/main.exe ./cmd/server"
delay = 800
exclude_dir = ["tmp", "vendor"]
exclude_regex = ["_test\\.go$"]
include_ext = ["go", "tpl", "tmpl", "html"]
log = "build-errors.log"
stop_on_error = true
[log]
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false

12
soul-api/.env Normal file
View File

@@ -0,0 +1,12 @@
# 服务
PORT=8080
GIN_MODE=debug
# 数据air库与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 可选:管理端鉴权密钥(若用 JWT
# JWT_SECRET=your-secret
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1

12
soul-api/.env.example Normal file
View File

@@ -0,0 +1,12 @@
# 服务
PORT=8080
GIN_MODE=debug
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 可选:管理端鉴权密钥(若用 JWT
# JWT_SECRET=your-secret
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1

9
soul-api/Makefile Normal file
View File

@@ -0,0 +1,9 @@
# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest
dev:
air
# 普通运行(无热重载)
run:
go run ./cmd/server
.PHONY: dev run

View File

@@ -0,0 +1,50 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/router"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("load config: ", err)
}
if err := database.Init(cfg.DBDSN); err != nil {
log.Fatal("database: ", err)
}
r := router.Setup(cfg)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("listen: ", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("server shutdown: ", err)
}
log.Println("bye")
}

46
soul-api/go.mod Normal file
View File

@@ -0,0 +1,46 @@
module soul-api
go 1.25
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/unrolled/secure v1.17.0
golang.org/x/time v0.8.0
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

116
soul-api/go.sum Normal file
View File

@@ -0,0 +1,116 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,42 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
// Config 应用配置(从环境变量读取)
type Config struct {
Port string
Mode string
DBDSN string
TrustedProxies []string
CORSOrigins []string
}
// Load 加载配置,开发环境可读 .env
func Load() (*Config, error) {
_ = godotenv.Load()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mode := os.Getenv("GIN_MODE")
if mode == "" {
mode = "debug"
}
dsn := os.Getenv("DB_DSN")
if dsn == "" {
dsn = "user:pass@tcp(127.0.0.1:3306)/soul?charset=utf8mb4&parseTime=True"
}
return &Config{
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: []string{"http://localhost:5174", "http://127.0.0.1:5174", "https://soul.quwanzhi.com"},
}, nil
}

View File

@@ -0,0 +1,26 @@
package database
import (
"log"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var db *gorm.DB
// Init 使用 DSN 连接 MySQL供 handler 通过 DB() 使用
func Init(dsn string) error {
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
log.Println("database: connected")
return nil
}
// DB 返回全局 *gorm.DB仅在 Init 成功后调用
func DB() *gorm.DB {
return db
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AdminCheck GET /api/admin 鉴权检查
func AdminCheck(c *gin.Context) {
// TODO: 校验 session/token返回 success: true 或 401
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminLogin POST /api/admin 登录
func AdminLogin(c *gin.Context) {
var body struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
// TODO: 校验用户名密码,写 session返回 success
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminLogout POST /api/admin/logout
func AdminLogout(c *gin.Context) {
// TODO: 清除 session
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,101 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树part -> chapters -> sections
func AdminChaptersList(c *gin.Context) {
var list []model.Chapter
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
return
}
type section struct {
ID string `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
IsFree bool `json:"isFree"`
Status string `json:"status"`
}
type chapter struct {
ID string `json:"id"`
Title string `json:"title"`
Sections []section `json:"sections"`
}
type part struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Chapters []chapter `json:"chapters"`
}
partMap := make(map[string]*part)
chapterMap := make(map[string]map[string]*chapter)
for _, row := range list {
if partMap[row.PartID] == nil {
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
chapterMap[row.PartID] = make(map[string]*chapter)
}
p := partMap[row.PartID]
if chapterMap[row.PartID][row.ChapterID] == nil {
ch := chapter{ID: row.ChapterID, Title: row.ChapterTitle, Sections: []section{}}
p.Chapters = append(p.Chapters, ch)
chapterMap[row.PartID][row.ChapterID] = &p.Chapters[len(p.Chapters)-1]
}
ch := chapterMap[row.PartID][row.ChapterID]
price := 1.0
if row.Price != nil {
price = *row.Price
}
isFree := false
if row.IsFree != nil {
isFree = *row.IsFree
}
st := "published"
if row.Status != nil {
st = *row.Status
}
ch.Sections = append(ch.Sections, section{ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st})
}
structure := make([]part, 0, len(partMap))
for _, p := range partMap {
structure = append(structure, *p)
}
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
})
}
// AdminChaptersAction POST/PUT/DELETE /api/admin/chapters
func AdminChaptersAction(c *gin.Context) {
var body struct {
Action string `json:"action"`
ID string `json:"id"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
if body.Action == "updatePrice" && body.ID != "" && body.Price != nil {
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price)
}
if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil {
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("is_free", *body.IsFree)
}
if body.Action == "updateStatus" && body.ID != "" && body.Status != nil {
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("status", *body.Status)
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,99 @@
package handler
import (
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// AdminDistributionOverview GET /api/admin/distribution/overview全部使用 GORM无 Raw SQL
func AdminDistributionOverview(c *gin.Context) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(24 * time.Hour)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
db := database.DB()
overview := gin.H{
"todayClicks": 0, "todayBindings": 0, "todayConversions": 0, "todayEarnings": 0,
"monthClicks": 0, "monthBindings": 0, "monthConversions": 0, "monthEarnings": 0,
"totalClicks": 0, "totalBindings": 0, "totalConversions": 0, "totalEarnings": 0,
"expiringBindings": 0, "pendingWithdrawals": 0, "pendingWithdrawAmount": 0,
"conversionRate": "0.00", "totalDistributors": 0, "activeDistributors": 0,
}
// 订单:仅用 Where + Count / Select(Sum) 参数化
var totalOrders int64
db.Model(&model.Order{}).Where("status = ?", "paid").Count(&totalOrders)
var totalAmount float64
db.Model(&model.Order{}).Where("status = ?", "paid").Select("COALESCE(SUM(amount),0)").Scan(&totalAmount)
var todayOrders int64
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Count(&todayOrders)
var todayAmount float64
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Select("COALESCE(SUM(amount),0)").Scan(&todayAmount)
var monthOrders int64
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Count(&monthOrders)
var monthAmount float64
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Select("COALESCE(SUM(amount),0)").Scan(&monthAmount)
overview["totalEarnings"] = totalAmount
overview["todayEarnings"] = todayAmount
overview["monthEarnings"] = monthAmount
// 绑定:全部 GORM Where
var totalBindings int64
db.Model(&model.ReferralBinding{}).Count(&totalBindings)
var converted int64
db.Model(&model.ReferralBinding{}).Where("status = ?", "converted").Count(&converted)
var todayBindings int64
db.Model(&model.ReferralBinding{}).Where("binding_date >= ? AND binding_date < ?", todayStart, todayEnd).Count(&todayBindings)
var todayConv int64
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ? AND binding_date < ?", "converted", todayStart, todayEnd).Count(&todayConv)
var monthBindings int64
db.Model(&model.ReferralBinding{}).Where("binding_date >= ?", monthStart).Count(&monthBindings)
var monthConv int64
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ?", "converted", monthStart).Count(&monthConv)
expiringEnd := now.Add(7 * 24 * time.Hour)
var expiring int64
db.Model(&model.ReferralBinding{}).Where("status = ? AND expiry_date > ? AND expiry_date <= ?", "active", now, expiringEnd).Count(&expiring)
overview["totalBindings"] = totalBindings
overview["totalConversions"] = converted
overview["todayBindings"] = todayBindings
overview["todayConversions"] = todayConv
overview["monthBindings"] = monthBindings
overview["monthConversions"] = monthConv
overview["expiringBindings"] = expiring
// 访问数
var visitTotal int64
db.Model(&model.ReferralVisit{}).Count(&visitTotal)
overview["totalClicks"] = visitTotal
if visitTotal > 0 && converted > 0 {
overview["conversionRate"] = formatPercent(float64(converted)/float64(visitTotal)*100)
}
// 提现待处理
var pendCount int64
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Count(&pendCount)
var pendSum float64
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Select("COALESCE(SUM(amount),0)").Scan(&pendSum)
overview["pendingWithdrawals"] = pendCount
overview["pendingWithdrawAmount"] = pendSum
// 分销商
var distTotal int64
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ?", "").Count(&distTotal)
var distActive int64
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ? AND earnings > ?", "", 0).Count(&distActive)
overview["totalDistributors"] = distTotal
overview["activeDistributors"] = distActive
c.JSON(http.StatusOK, gin.H{"success": true, "overview": overview})
}
func formatPercent(v float64) string {
return fmt.Sprintf("%.2f", v) + "%"
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AdminContent GET/POST/PUT/DELETE /api/admin/content
func AdminContent(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminPayment GET/POST/PUT/DELETE /api/admin/payment
func AdminPayment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminReferral GET/POST/PUT/DELETE /api/admin/referral
func AdminReferral(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,119 @@
package handler
import (
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// AdminWithdrawalsList GET /api/admin/withdrawals
func AdminWithdrawalsList(c *gin.Context) {
statusFilter := c.Query("status")
var list []model.Withdrawal
q := database.DB().Order("created_at DESC").Limit(100)
if statusFilter != "" {
q = q.Where("status = ?", statusFilter)
}
if err := q.Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})
return
}
userIds := make([]string, 0, len(list))
seen := make(map[string]bool)
for _, w := range list {
if !seen[w.UserID] {
seen[w.UserID] = true
userIds = append(userIds, w.UserID)
}
}
var users []model.User
if len(userIds) > 0 {
database.DB().Where("id IN ?", userIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
withdrawals := make([]gin.H, 0, len(list))
for _, w := range list {
u := userMap[w.UserID]
userName := "未知用户"
var userAvatar *string
account := "未绑定微信号"
if w.WechatID != nil && *w.WechatID != "" {
account = *w.WechatID
}
if u != nil {
if u.Nickname != nil {
userName = *u.Nickname
}
userAvatar = u.Avatar
if u.WechatID != nil && *u.WechatID != "" {
account = *u.WechatID
}
}
st := "pending"
if w.Status != nil {
st = *w.Status
if st == "success" {
st = "completed"
} else if st == "failed" {
st = "rejected"
} else if st == "pending_confirm" {
st = "pending_confirm"
}
}
withdrawals = append(withdrawals, gin.H{
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
"method": "wechat", "account": account,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "withdrawals": withdrawals, "stats": gin.H{"total": len(withdrawals)}})
}
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
func AdminWithdrawalsAction(c *gin.Context) {
var body struct {
ID string `json:"id"`
Action string `json:"action"`
ErrorMessage string `json:"errorMessage"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
return
}
reason := body.ErrorMessage
if reason == "" {
reason = body.Reason
}
if reason == "" && body.Action == "reject" {
reason = "管理员拒绝"
}
var newStatus string
switch body.Action {
case "approve":
newStatus = "success"
case "reject":
newStatus = "failed"
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
return
}
now := time.Now()
err := database.DB().Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
"status": newStatus,
"error_message": reason,
"processed_at": now,
}).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "操作成功"})
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AuthLogin POST /api/auth/login
func AuthLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AuthResetPassword POST /api/auth/reset-password
func AuthResetPassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,160 @@
package handler
import (
"net/http"
"strconv"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
func BookAllChapters(c *gin.Context) {
var list []model.Chapter
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookChapterByID GET /api/book/chapter/:id
func BookChapterByID(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
var ch model.Chapter
if err := database.DB().Where("id = ?", id).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": ch})
}
// BookChapters GET/POST/PUT/DELETE /api/book/chapters与 app/api/book/chapters 一致,用 GORM
func BookChapters(c *gin.Context) {
db := database.DB()
switch c.Request.Method {
case http.MethodGet:
partId := c.Query("partId")
status := c.Query("status")
if status == "" {
status = "published"
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 100
}
q := db.Model(&model.Chapter{})
if partId != "" {
q = q.Where("part_id = ?", partId)
}
if status != "" && status != "all" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var list []model.Chapter
q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
},
})
return
case http.MethodPost:
var body model.Chapter
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.ID == "" || body.PartID == "" || body.ChapterID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"})
return
}
if err := db.Create(&body).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
return
case http.MethodPut:
var body model.Chapter
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
"sort_order": body.SortOrder, "status": body.Status,
}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
case http.MethodDelete:
id := c.Query("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
// BookHot GET /api/book/hot
func BookHot(c *gin.Context) {
var list []model.Chapter
database.DB().Order("sort_order ASC, id ASC").Limit(10).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookLatestChapters GET /api/book/latest-chapters
func BookLatestChapters(c *gin.Context) {
var list []model.Chapter
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookSearch GET /api/book/search 同 /api/search由 SearchGet 处理
func BookSearch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
}
// BookStats GET /api/book/stats
func BookStats(c *gin.Context) {
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
}
// BookSync GET/POST /api/book/sync
func BookSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBMatch POST /api/ckb/match
func CKBMatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBSync GET/POST /api/ckb/sync
func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,63 @@
package handler
import (
"encoding/json"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// GetConfig GET /api/config 从 system_config 读取并合并(与 app/api/config 结构一致)
func GetConfig(c *gin.Context) {
var list []model.SystemConfig
if err := database.DB().Order("config_key ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
})
return
}
out := gin.H{
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
"authorInfo": gin.H{}, "marketing": gin.H{}, "system": gin.H{},
}
for _, row := range list {
var val interface{}
_ = json.Unmarshal(row.ConfigValue, &val)
switch row.ConfigKey {
case "site_config", "siteConfig":
if m, ok := val.(map[string]interface{}); ok {
out["siteConfig"] = m
}
case "menu_config", "menuConfig":
out["menuConfig"] = val
case "page_config", "pageConfig":
if m, ok := val.(map[string]interface{}); ok {
out["pageConfig"] = m
}
case "payment_methods", "paymentMethods":
if m, ok := val.(map[string]interface{}); ok {
out["paymentMethods"] = m
}
case "live_qr_codes", "liveQRCodes":
out["liveQRCodes"] = val
case "author_info", "authorInfo":
if m, ok := val.(map[string]interface{}); ok {
out["authorInfo"] = m
}
case "marketing":
if m, ok := val.(map[string]interface{}); ok {
out["marketing"] = m
}
case "system":
if m, ok := val.(map[string]interface{}); ok {
out["system"] = m
}
}
}
c.JSON(http.StatusOK, out)
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ContentGet GET /api/content
func ContentGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CronSyncOrders GET/POST /api/cron/sync-orders
func CronSyncOrders(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,391 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBConfigGet GET /api/db/config
func DBConfigGet(c *gin.Context) {
key := c.Query("key")
db := database.DB()
var list []model.SystemConfig
q := db.Table("system_config")
if key != "" {
q = q.Where("config_key = ?", key)
}
if err := q.Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if key != "" && len(list) == 1 {
var val interface{}
_ = json.Unmarshal(list[0].ConfigValue, &val)
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
return
}
data := make([]gin.H, 0, len(list))
for _, row := range list {
var val interface{}
_ = json.Unmarshal(row.ConfigValue, &val)
data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// DBConfigPost POST /api/db/config
func DBConfigPost(c *gin.Context) {
var body struct {
Key string `json:"key"`
Value interface{} `json:"value"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
return
}
valBytes, err := json.Marshal(body.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
db := database.DB()
desc := body.Description
var row model.SystemConfig
err = db.Where("config_key = ?", body.Key).First(&row).Error
if err != nil {
row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc}
err = db.Create(&row).Error
} else {
row.ConfigValue = valBytes
if body.Description != "" {
row.Description = &desc
}
err = db.Save(&row).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}
// DBUsersList GET /api/db/users
func DBUsersList(c *gin.Context) {
var users []model.User
if err := database.DB().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})
}
// DBUsersAction POST /api/db/users创建、PUT /api/db/users更新
func DBUsersAction(c *gin.Context) {
db := database.DB()
if c.Request.Method == http.MethodPost {
var body struct {
OpenID *string `json:"openId"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
IsAdmin *bool `json:"isAdmin"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := "user_" + randomSuffix()
code := "SOUL" + randomSuffix()[:4]
nick := "用户"
if body.Nickname != nil && *body.Nickname != "" {
nick = *body.Nickname
} else {
nick = nick + userID[len(userID)-4:]
}
u := model.User{
ID: userID, Nickname: &nick, ReferralCode: &code,
OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar,
}
if body.IsAdmin != nil {
u.IsAdmin = body.IsAdmin
}
if err := db.Create(&u).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
return
}
// PUT 更新
var body struct {
ID string `json:"id"`
Nickname *string `json:"nickname"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
PendingEarnings *float64 `json:"pendingEarnings"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
updates := map[string]interface{}{}
if body.Nickname != nil {
updates["nickname"] = *body.Nickname
}
if body.Phone != nil {
updates["phone"] = *body.Phone
}
if body.WechatID != nil {
updates["wechat_id"] = *body.WechatID
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
}
if body.IsAdmin != nil {
updates["is_admin"] = *body.IsAdmin
}
if body.Earnings != nil {
updates["earnings"] = *body.Earnings
}
if body.PendingEarnings != nil {
updates["pending_earnings"] = *body.PendingEarnings
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
}
if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"})
}
func randomSuffix() string {
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
}
// DBUsersDelete DELETE /api/db/users
func DBUsersDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
}
// DBUsersReferrals GET /api/db/users/referrals
func DBUsersReferrals(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
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}})
return
}
refereeIds := make([]string, 0, len(bindings))
for _, b := range bindings {
refereeIds = append(refereeIds, b.RefereeID)
}
var users []model.User
if len(refereeIds) > 0 {
db.Where("id IN ?", refereeIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
referrals := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
u := userMap[b.RefereeID]
nick := "微信用户"
var avatar *string
var phone *string
hasFullBook := false
if u != nil {
if u.Nickname != nil {
nick = *u.Nickname
}
avatar, phone = u.Avatar, u.Phone
if u.HasFullBook != nil {
hasFullBook = *u.HasFullBook
}
}
status := "active"
if b.Status != nil {
status = *b.Status
}
daysRemaining := 0
if b.ExpiryDate.After(time.Now()) {
daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
}
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,
})
}
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
}
}
purchased := 0
for _, b := range bindings {
u := userMap[b.RefereeID]
if (u != nil && u.HasFullBook != nil && *u.HasFullBook) || (b.Status != nil && *b.Status == "converted") {
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,
},
})
}
// DBInit POST /api/db/init
func DBInit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})
}
// DBDistribution GET /api/db/distribution
func DBDistribution(c *gin.Context) {
userId := c.Query("userId")
db := database.DB()
var bindings []model.ReferralBinding
q := db.Order("binding_date DESC").Limit(500)
if userId != "" {
q = q.Where("referrer_id = ?", userId)
}
if err := q.Find(&bindings).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0})
return
}
referrerIds := make(map[string]bool)
refereeIds := make(map[string]bool)
for _, b := range bindings {
referrerIds[b.ReferrerID] = true
refereeIds[b.RefereeID] = true
}
allIds := make([]string, 0, len(referrerIds)+len(refereeIds))
for id := range referrerIds {
allIds = append(allIds, id)
}
for id := range refereeIds {
if !referrerIds[id] {
allIds = append(allIds, id)
}
}
var users []model.User
if len(allIds) > 0 {
db.Where("id IN ?", allIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
refNick := "用户"
if u := userMap[b.RefereeID]; u != nil && u.Nickname != nil {
refNick = *u.Nickname
} else {
refNick = refNick + b.RefereeID
}
var referrerName *string
if u := userMap[b.ReferrerID]; u != nil {
referrerName = u.Nickname
}
days := 0
if b.ExpiryDate.After(time.Now()) {
days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
}
var refereePhone *string
if u := userMap[b.RefereeID]; u != nil {
refereePhone = u.Phone
}
out = append(out, gin.H{
"id": b.ID, "referrer_id": b.ReferrerID, "referrer_name": referrerName, "referrer_code": b.ReferralCode,
"referee_id": b.RefereeID, "referee_nickname": refNick, "referee_phone": refereePhone,
"bound_at": b.BindingDate, "expires_at": b.ExpiryDate, "status": b.Status,
"days_remaining": days, "commission": b.CommissionAmount, "source": "miniprogram",
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": out, "total": len(out)})
}
// DBChapters GET/POST /api/db/chapters
func DBChapters(c *gin.Context) {
var list []model.Chapter
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// DBConfigDelete DELETE /api/db/config
func DBConfigDelete(c *gin.Context) {
key := c.Query("key")
if key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
return
}
if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DBInitGet GET /api/db/init
func DBInitGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
}
// DBMigrateGet GET /api/db/migrate
func DBMigrateGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"})
}
// DBMigratePost POST /api/db/migrate
func DBMigratePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"})
}

View File

@@ -0,0 +1,247 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
FilePath *string `json:"filePath,omitempty"`
}
// DBBookAction GET/POST/PUT /api/db/book
func DBBookAction(c *gin.Context) {
db := database.DB()
switch c.Request.Method {
case http.MethodGet:
action := c.Query("action")
id := c.Query("id")
switch action {
case "list":
var rows []model.Chapter
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "read":
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
var ch model.Chapter
if err := db.Where("id = ?", id).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"section": gin.H{
"id": ch.ID,
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
},
})
return
case "export":
var rows []model.Chapter
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree,
PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
return
}
case http.MethodPost:
var body struct {
Action string `json:"action"`
Data []importItem `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
switch body.Action {
case "sync":
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成Gin 无文件源时可从 DB 已存在数据视为已同步)"})
return
case "import":
imported, failed := 0, 0
for _, item := range body.Data {
price := 1.0
if item.Price != nil {
price = *item.Price
}
isFree := false
if item.IsFree != nil {
isFree = *item.IsFree
}
wordCount := len(item.Content)
status := "published"
ch := model.Chapter{
ID: item.ID,
PartID: strPtr(item.PartID, "part-1"),
PartTitle: strPtr(item.PartTitle, "未分类"),
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
}
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
if err == gorm.ErrRecordNotFound {
err = db.Create(&ch).Error
} else if err == nil {
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"section_title": ch.SectionTitle,
"content": ch.Content,
"word_count": ch.WordCount,
"is_free": ch.IsFree,
"price": ch.Price,
}).Error
}
if err != nil {
failed++
continue
}
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
return
}
case http.MethodPut:
var body struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
return
}
price := 1.0
if body.Price != nil {
price = *body.Price
}
isFree := false
if body.IsFree != nil {
isFree = *body.IsFree
}
wordCount := len(body.Content)
updates := map[string]interface{}{
"section_title": body.Title,
"content": body.Content,
"word_count": wordCount,
"price": price,
"is_free": isFree,
}
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
type importItem struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
PartID *string `json:"partId"`
PartTitle *string `json:"partTitle"`
ChapterID *string `json:"chapterId"`
ChapterTitle *string `json:"chapterTitle"`
}
func strPtr(s *string, def string) string {
if s != nil && *s != "" {
return *s
}
return def
}
// DBBookDelete DELETE /api/db/book
func DBBookDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// DistributionGet POST /api/distribution GET/POST/PUT
func DistributionGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DistributionAutoWithdrawConfig GET/POST/DELETE /api/distribution/auto-withdraw-config
func DistributionAutoWithdrawConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DistributionMessages GET/POST /api/distribution/messages
func DistributionMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// DocGenerate POST /api/documentation/generate
func DocGenerate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// MatchConfigGet GET /api/match/config
func MatchConfigGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MatchConfigPost POST /api/match/config
func MatchConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MatchUsers POST /api/match/users (Next 为 POST拆解计划写 GET两法都挂)
func MatchUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// MenuGet GET /api/menu
func MenuGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
}

View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// MiniprogramLogin POST /api/miniprogram/login
func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MiniprogramPay GET/POST /api/miniprogram/pay
func MiniprogramPay(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MiniprogramPayNotify POST /api/miniprogram/pay/notify
func MiniprogramPayNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MiniprogramPhone POST /api/miniprogram/phone
func MiniprogramPhone(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MiniprogramQrcode POST /api/miniprogram/qrcode (Next 为 POST)
func MiniprogramQrcode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,20 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// OrdersList GET /api/orders
func OrdersList(c *gin.Context) {
var orders []model.Order
if err := database.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})
}

View File

@@ -0,0 +1,52 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// PaymentAlipayNotify POST /api/payment/alipay/notify
func PaymentAlipayNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentCallback POST /api/payment/callback
func PaymentCallback(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentCreateOrder POST /api/payment/create-order
func PaymentCreateOrder(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentMethods GET /api/payment/methods
func PaymentMethods(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
}
// PaymentQuery GET /api/payment/query
func PaymentQuery(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentStatusOrderSn GET /api/payment/status/:orderSn
func PaymentStatusOrderSn(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentVerify POST /api/payment/verify
func PaymentVerify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentWechatNotify POST /api/payment/wechat/notify
func PaymentWechatNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
func PaymentWechatTransferNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ReferralBind POST /api/referral/bind
func ReferralBind(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ReferralData GET /api/referral/data
func ReferralData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ReferralVisit POST /api/referral/visit
func ReferralVisit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,81 @@
package handler
import (
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用
func escapeLike(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}
// SearchGet GET /api/search?q= 从 chapters 表搜索GORM参数化
func SearchGet(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"})
return
}
pattern := "%" + escapeLike(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(50).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
return
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
score := 5
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
score = 10
}
snippet := ""
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
if pos >= 0 && len(ch.Content) > 0 {
start := pos - 50
if start < 0 {
start = 0
}
end := pos + utf8.RuneCountInString(q) + 50
if end > len(ch.Content) {
end = len(ch.Content)
}
snippet = ch.Content[start:end]
if start > 0 {
snippet = "..." + snippet
}
if end < len(ch.Content) {
snippet = snippet + "..."
}
}
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"keyword": q, "total": len(results), "results": results},
})
}

View File

@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// SyncGet GET /api/sync
func SyncGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SyncPost POST /api/sync
func SyncPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SyncPut PUT /api/sync
func SyncPut(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// UploadPost POST /api/upload
func UploadPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "url": ""})
}
// UploadDelete DELETE /api/upload
func UploadDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,157 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// UserAddressesGet GET /api/user/addresses
func UserAddressesGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
}
// UserAddressesPost POST /api/user/addresses
func UserAddressesPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id
func UserAddressesByID(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserCheckPurchased GET /api/user/check-purchased
func UserCheckPurchased(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserProfileGet GET /api/user/profile
func UserProfileGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserProfilePost POST /api/user/profile
func UserProfilePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserPurchaseStatus GET /api/user/purchase-status
func UserPurchaseStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserReadingProgressGet GET /api/user/reading-progress
func UserReadingProgressGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserReadingProgressPost POST /api/user/reading-progress
func UserReadingProgressPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查GORM
func UserTrackGet(c *gin.Context) {
userId := c.Query("userId")
phone := c.Query("phone")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit < 1 || limit > 100 {
limit = 50
}
if userId == "" && phone == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
return
}
db := database.DB()
if userId == "" && phone != "" {
var u model.User
if err := db.Where("phone = ?", phone).First(&u).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
userId = u.ID
}
var tracks []model.UserTrack
if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0})
return
}
stats := make(map[string]int)
formatted := make([]gin.H, 0, len(tracks))
for _, t := range tracks {
stats[t.Action]++
target := ""
if t.Target != nil {
target = *t.Target
}
if t.ChapterID != nil && target == "" {
target = *t.ChapterID
}
formatted = append(formatted, gin.H{
"id": t.ID, "action": t.Action, "target": target, "chapterTitle": t.ChapterID,
"createdAt": t.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
}
// UserTrackPost POST /api/user/track 记录行为GORM
func UserTrackPost(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
Action string `json:"action"`
Target string `json:"target"`
ExtraData interface{} `json:"extraData"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.UserID == "" && body.Phone == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
return
}
if body.Action == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
return
}
db := database.DB()
userId := body.UserID
if userId == "" {
var u model.User
if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
userId = u.ID
}
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
chID := body.Target
if body.Action == "view_chapter" {
chID = body.Target
}
t := model.UserTrack{
ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
}
if body.Target != "" {
t.ChapterID = &chID
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
}
// UserUpdate POST /api/user/update
func UserUpdate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// WechatLogin POST /api/wechat/login
func WechatLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,60 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// WithdrawPost POST /api/withdraw 创建提现申请(占位:仅返回成功,实际需对接微信打款)
func WithdrawPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录GORM
func WithdrawRecords(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
return
}
var list []model.Withdrawal
if err := database.DB().Where("user_id = ?", userId).Order("created_at DESC").Limit(100).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}}})
return
}
out := make([]gin.H, 0, len(list))
for _, w := range list {
st := ""
if w.Status != nil {
st = *w.Status
}
out = append(out, gin.H{
"id": w.ID, "amount": w.Amount, "status": st,
"createdAt": w.CreatedAt, "processedAt": w.ProcessedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
}
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表GORM
func WithdrawPendingConfirm(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
return
}
var list []model.Withdrawal
if err := database.DB().Where("user_id = ? AND status = ?", userId, "pending_confirm").Order("created_at DESC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}, "mch_id": "", "app_id": ""}})
return
}
out := make([]gin.H, 0, len(list))
for _, w := range list {
out = append(out, gin.H{"id": w.ID, "amount": w.Amount, "createdAt": w.CreatedAt})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out, "mchId": "", "appId": ""}})
}

View File

@@ -0,0 +1,25 @@
package middleware
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
)
// AdminAuth 管理端鉴权校验登录态Cookie 或 Authorization未登录返回 401
// 开发模式GIN_MODE=debug下暂不校验便于联调生产请实现 Session/JWT
func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if os.Getenv("GIN_MODE") == "debug" {
c.Next()
return
}
_, err := c.Cookie("admin_session")
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未登录"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,65 @@
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/gin-gonic/gin"
)
// RateLimiter 按 IP 的限流器
type RateLimiter struct {
mu sync.Mutex
clients map[string]*rate.Limiter
r rate.Limit
b int
}
// NewRateLimiter 创建限流中间件r 每秒请求数b 突发容量
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
return &RateLimiter{
clients: make(map[string]*rate.Limiter),
r: r,
b: b,
}
}
// getLimiter 获取或创建该 key 的 limiter
func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
if lim, ok := rl.clients[key]; ok {
return lim
}
lim := rate.NewLimiter(rl.r, rl.b)
rl.clients[key] = lim
return lim
}
// Middleware 返回 Gin 限流中间件(按客户端 IP
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
key := c.ClientIP()
lim := rl.getLimiter(key)
if !lim.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
}
// Cleanup 定期清理过期 limiter可选避免 map 无限增长)
func (rl *RateLimiter) Cleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
rl.mu.Lock()
rl.clients = make(map[string]*rate.Limiter)
rl.mu.Unlock()
}
}()
}

View File

@@ -0,0 +1,25 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/unrolled/secure"
)
// Secure 安全响应头中间件
func Secure() gin.HandlerFunc {
s := secure.New(secure.Options{
FrameDeny: true,
ContentTypeNosniff: true,
BrowserXssFilter: true,
ContentSecurityPolicy: "frame-ancestors 'none'",
ReferrerPolicy: "no-referrer",
})
return func(c *gin.Context) {
err := s.Process(c.Writer, c.Request)
if err != nil {
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1 @@
在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。

View File

@@ -0,0 +1,23 @@
package model
import "time"
// Chapter 对应表 chapters与 Prisma 一致JSON 小写驼峰
type Chapter struct {
ID string `gorm:"column:id;primaryKey;size:20" json:"id"`
PartID string `gorm:"column:part_id;size:20" json:"partId"`
PartTitle string `gorm:"column:part_title;size:100" json:"partTitle"`
ChapterID string `gorm:"column:chapter_id;size:20" json:"chapterId"`
ChapterTitle string `gorm:"column:chapter_title;size:200" json:"chapterTitle"`
SectionTitle string `gorm:"column:section_title;size:200" json:"sectionTitle"`
Content string `gorm:"column:content;type:longtext" json:"content,omitempty"`
WordCount *int `gorm:"column:word_count" json:"wordCount,omitempty"`
IsFree *bool `gorm:"column:is_free" json:"isFree,omitempty"`
Price *float64 `gorm:"column:price;type:decimal(10,2)" json:"price,omitempty"`
SortOrder *int `gorm:"column:sort_order" json:"sortOrder,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Chapter) TableName() string { return "chapters" }

View File

@@ -0,0 +1,24 @@
package model
import "time"
// Order 对应表 ordersJSON 输出与现网接口 1:1小写驼峰
type Order struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Order) TableName() string { return "orders" }

View File

@@ -0,0 +1,19 @@
package model
import "time"
// ReferralBinding 对应表 referral_bindings
type ReferralBinding struct {
ID string `gorm:"column:id;primaryKey;size:50"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
RefereeID string `gorm:"column:referee_id;size:50"`
ReferralCode string `gorm:"column:referral_code;size:20"`
Status *string `gorm:"column:status;size:20"`
BindingDate time.Time `gorm:"column:binding_date"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (ReferralBinding) TableName() string { return "referral_bindings" }

View File

@@ -0,0 +1,13 @@
package model
import "time"
// ReferralVisit 对应表 referral_visits
type ReferralVisit struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
VisitorID *string `gorm:"column:visitor_id;size:50"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (ReferralVisit) TableName() string { return "referral_visits" }

View File

@@ -0,0 +1,35 @@
package model
import (
"database/sql/driver"
"time"
)
// ConfigValue 存 system_config.config_valueJSON 列,可为 object 或 array
type ConfigValue []byte
func (c ConfigValue) Value() (driver.Value, error) { return []byte(c), nil }
func (c *ConfigValue) Scan(value interface{}) error {
if value == nil {
*c = nil
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
*c = append((*c)[0:0], b...)
return nil
}
// SystemConfig 对应表 system_configJSON 输出与现网 1:1小写驼峰
type SystemConfig struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ConfigKey string `gorm:"column:config_key;uniqueIndex;size:100" json:"configKey"`
ConfigValue ConfigValue `gorm:"column:config_value;type:json" json:"configValue"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (SystemConfig) TableName() string { return "system_config" }

View File

@@ -0,0 +1,26 @@
package model
import "time"
// User 对应表 usersJSON 输出与现网接口 1:1小写驼峰
type User struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"`
HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"`
Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"`
PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"`
ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
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"`
}
func (User) TableName() string { return "users" }

View File

@@ -0,0 +1,16 @@
package model
import "time"
// UserTrack 对应表 user_tracks
type UserTrack struct {
ID string `gorm:"column:id;primaryKey;size:50"`
UserID string `gorm:"column:user_id;size:100"`
Action string `gorm:"column:action;size:50"`
ChapterID *string `gorm:"column:chapter_id;size:100"`
Target *string `gorm:"column:target;size:200"`
ExtraData []byte `gorm:"column:extra_data;type:json"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
func (UserTrack) TableName() string { return "user_tracks" }

View File

@@ -0,0 +1,17 @@
package model
import "time"
// Withdrawal 对应表 withdrawals
type Withdrawal struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Status *string `gorm:"column:status;size:20" json:"status"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
}
func (Withdrawal) TableName() string { return "withdrawals" }

View File

@@ -0,0 +1 @@
在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。

View File

@@ -0,0 +1,213 @@
package router
import (
"soul-api/internal/config"
"soul-api/internal/handler"
"soul-api/internal/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// Setup 创建并配置 Gin 引擎,路径与 app/api 一致
func Setup(cfg *config.Config) *gin.Engine {
gin.SetMode(cfg.Mode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
_ = r.SetTrustedProxies(cfg.TrustedProxies)
r.Use(middleware.Secure())
r.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 86400,
}))
rateLimiter := middleware.NewRateLimiter(100, 200)
r.Use(rateLimiter.Middleware())
api := r.Group("/api")
{
// ----- 管理端 -----
api.GET("/admin", handler.AdminCheck)
api.POST("/admin", handler.AdminLogin)
api.POST("/admin/logout", handler.AdminLogout)
admin := api.Group("/admin")
admin.Use(middleware.AdminAuth())
{
admin.GET("/chapters", handler.AdminChaptersList)
admin.POST("/chapters", handler.AdminChaptersAction)
admin.PUT("/chapters", handler.AdminChaptersAction)
admin.DELETE("/chapters", handler.AdminChaptersAction)
admin.GET("/content", handler.AdminContent)
admin.POST("/content", handler.AdminContent)
admin.PUT("/content", handler.AdminContent)
admin.DELETE("/content", handler.AdminContent)
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
admin.GET("/payment", handler.AdminPayment)
admin.POST("/payment", handler.AdminPayment)
admin.PUT("/payment", handler.AdminPayment)
admin.DELETE("/payment", handler.AdminPayment)
admin.GET("/referral", handler.AdminReferral)
admin.POST("/referral", handler.AdminReferral)
admin.PUT("/referral", handler.AdminReferral)
admin.DELETE("/referral", handler.AdminReferral)
admin.GET("/withdrawals", handler.AdminWithdrawalsList)
admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
}
// ----- 鉴权 -----
api.POST("/auth/login", handler.AuthLogin)
api.POST("/auth/reset-password", handler.AuthResetPassword)
// ----- 书籍/章节 -----
api.GET("/book/all-chapters", handler.BookAllChapters)
api.GET("/book/chapter/:id", handler.BookChapterByID)
api.GET("/book/chapters", handler.BookChapters)
api.POST("/book/chapters", handler.BookChapters)
api.PUT("/book/chapters", handler.BookChapters)
api.DELETE("/book/chapters", handler.BookChapters)
api.GET("/book/hot", handler.BookHot)
api.GET("/book/latest-chapters", handler.BookLatestChapters)
api.GET("/book/search", handler.BookSearch)
api.GET("/book/stats", handler.BookStats)
api.GET("/book/sync", handler.BookSync)
api.POST("/book/sync", handler.BookSync)
// ----- CKB -----
api.POST("/ckb/join", handler.CKBJoin)
api.POST("/ckb/match", handler.CKBMatch)
api.GET("/ckb/sync", handler.CKBSync)
api.POST("/ckb/sync", handler.CKBSync)
// ----- 配置 -----
api.GET("/config", handler.GetConfig)
// ----- 内容 -----
api.GET("/content", handler.ContentGet)
// ----- 定时任务 -----
api.GET("/cron/sync-orders", handler.CronSyncOrders)
api.POST("/cron/sync-orders", handler.CronSyncOrders)
api.GET("/cron/unbind-expired", handler.CronUnbindExpired)
api.POST("/cron/unbind-expired", handler.CronUnbindExpired)
// ----- 数据库(管理端) -----
db := api.Group("/db")
db.Use(middleware.AdminAuth())
{
db.GET("/book", handler.DBBookAction)
db.POST("/book", handler.DBBookAction)
db.PUT("/book", handler.DBBookAction)
db.DELETE("/book", handler.DBBookDelete)
db.GET("/chapters", handler.DBChapters)
db.POST("/chapters", handler.DBChapters)
db.GET("/config", handler.DBConfigGet)
db.POST("/config", handler.DBConfigPost)
db.DELETE("/config", handler.DBConfigDelete)
db.GET("/distribution", handler.DBDistribution)
db.GET("/init", handler.DBInitGet)
db.POST("/init", handler.DBInit)
db.GET("/migrate", handler.DBMigrateGet)
db.POST("/migrate", handler.DBMigratePost)
db.GET("/users", handler.DBUsersList)
db.POST("/users", handler.DBUsersAction)
db.PUT("/users", handler.DBUsersAction)
db.DELETE("/users", handler.DBUsersDelete)
db.GET("/users/referrals", handler.DBUsersReferrals)
}
// ----- 分销 -----
api.GET("/distribution", handler.DistributionGet)
api.POST("/distribution", handler.DistributionGet)
api.PUT("/distribution", handler.DistributionGet)
api.GET("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.POST("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.DELETE("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.GET("/distribution/messages", handler.DistributionMessages)
api.POST("/distribution/messages", handler.DistributionMessages)
// ----- 文档生成 -----
api.POST("/documentation/generate", handler.DocGenerate)
// ----- 找伙伴 -----
api.GET("/match/config", handler.MatchConfigGet)
api.POST("/match/config", handler.MatchConfigPost)
api.POST("/match/users", handler.MatchUsers)
// ----- 菜单 -----
api.GET("/menu", handler.MenuGet)
// ----- 小程序 -----
api.POST("/miniprogram/login", handler.MiniprogramLogin)
api.GET("/miniprogram/pay", handler.MiniprogramPay)
api.POST("/miniprogram/pay", handler.MiniprogramPay)
api.POST("/miniprogram/pay/notify", handler.MiniprogramPayNotify)
api.POST("/miniprogram/phone", handler.MiniprogramPhone)
api.POST("/miniprogram/qrcode", handler.MiniprogramQrcode)
// ----- 订单 -----
api.GET("/orders", handler.OrdersList)
// ----- 支付 -----
api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
api.POST("/payment/callback", handler.PaymentCallback)
api.POST("/payment/create-order", handler.PaymentCreateOrder)
api.GET("/payment/methods", handler.PaymentMethods)
api.GET("/payment/query", handler.PaymentQuery)
api.GET("/payment/status/:orderSn", handler.PaymentStatusOrderSn)
api.POST("/payment/verify", handler.PaymentVerify)
api.POST("/payment/wechat/notify", handler.PaymentWechatNotify)
api.POST("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
// ----- 推荐 -----
api.POST("/referral/bind", handler.ReferralBind)
api.GET("/referral/data", handler.ReferralData)
api.POST("/referral/visit", handler.ReferralVisit)
// ----- 搜索 -----
api.GET("/search", handler.SearchGet)
// ----- 同步 -----
api.GET("/sync", handler.SyncGet)
api.POST("/sync", handler.SyncPost)
api.PUT("/sync", handler.SyncPut)
// ----- 上传 -----
api.POST("/upload", handler.UploadPost)
api.DELETE("/upload", handler.UploadDelete)
// ----- 用户 -----
api.GET("/user/addresses", handler.UserAddressesGet)
api.POST("/user/addresses", handler.UserAddressesPost)
api.GET("/user/addresses/:id", handler.UserAddressesByID)
api.PUT("/user/addresses/:id", handler.UserAddressesByID)
api.DELETE("/user/addresses/:id", handler.UserAddressesByID)
api.GET("/user/check-purchased", handler.UserCheckPurchased)
api.GET("/user/profile", handler.UserProfileGet)
api.POST("/user/profile", handler.UserProfilePost)
api.GET("/user/purchase-status", handler.UserPurchaseStatus)
api.GET("/user/reading-progress", handler.UserReadingProgressGet)
api.POST("/user/reading-progress", handler.UserReadingProgressPost)
api.GET("/user/track", handler.UserTrackGet)
api.POST("/user/track", handler.UserTrackPost)
api.POST("/user/update", handler.UserUpdate)
// ----- 微信登录 -----
api.POST("/wechat/login", handler.WechatLogin)
// ----- 提现 -----
api.POST("/withdraw", handler.WithdrawPost)
api.GET("/withdraw/records", handler.WithdrawRecords)
api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
return r
}

View File

@@ -0,0 +1 @@
在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。

BIN
soul-api/server.exe Normal file

Binary file not shown.

BIN
soul-api/tmp/main.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,120 @@
# Gin 技术栈依赖清单(适配 Go 1.25.7
**目标版本**Go 1.25.7
**原则**:精简、高效、易上手、好用、安全;所有依赖均兼容 Go 1.25,无需更换或移除。
---
## 一、Go 版本说明
- Go 1.25 于 2025 年 8 月发布,遵守 Go 1 兼容性承诺,现有主流库均可使用。
- **Gin** 官方要求 Go 1.24+1.25.7 满足要求。
- **golang.org/x/\***crypto、time 等)随 Go 工具链维护,支持当前稳定版。
- 若某依赖未显式声明支持 1.25,只要其 `go.mod``go 1.21` 或更高,在 1.25 下均可正常编译使用。
---
## 二、依赖列表(均适配 Go 1.25.7
### 1. 核心与数据库
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/gin-gonic/gin` | 最新 v1.x | 要求 Go 1.24+1.25.7 兼容。 |
| `gorm.io/gorm` | 最新 v1.31.x | 无对高版本 Go 的限制。 |
| `gorm.io/driver/mysql` | 最新 | 与 GORM 配套。 |
### 2. 安全
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/unrolled/secure` | v1.17+ | 标准 net/http 中间件,兼容 Go 1.25。 |
| `golang.org/x/crypto` | 最新 | 使用 `bcrypt` 等,随 Go 生态更新。 |
| `golang.org/x/time` | 最新 | 使用 `rate` 限流,兼容 Go 1.25。 |
### 3. 配置
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/joho/godotenv` | v1.5.x | 仅读 .env无高版本 Go 要求。 |
| 或 `github.com/caarlos0/env/v11` | v11.x | 解析 env 到结构体,兼容当前 Go。 |
### 4. 跨域与鉴权
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/gin-contrib/cors` | v1.6+(务必 ≥1.6,修复 CVE | 与 Gin 1.24+ / Go 1.25 兼容。 |
| `github.com/golang-jwt/jwt/v5` | 最新 v5.x | 推荐 v5与 Go 1.25 兼容。 |
### 5. 接口文档(可选)
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/swaggo/swag/cmd/swag` | 最新CLI 工具) | 代码生成,使用最新 CLI 即可。 |
| `github.com/swaggo/gin-swagger` | 最新 | 与 Gin 配套。 |
| `github.com/swaggo/files` | 最新 | Swagger UI 静态资源。 |
### 6. 开发工具(仅开发环境)
| 依赖 | 版本建议 | 说明 |
|------|----------|------|
| `github.com/cosmtrek/air` | 最新 | 热重载,与 Go 1.25 兼容。 |
---
## 三、无需更换或移除
- 上述依赖在 Go 1.25.7 下**均无需更换或移除**。
- 未列入的冗余依赖(如单独再引入 validator、Viper、zap 等)按此前「精简版」建议已不纳入,无需因 Go 1.25 再改。
---
## 四、推荐 go.mod 片段Go 1.25
在项目根目录执行:
```bash
go mod init soul-server
go mod edit -go=1.25
```
然后按需拉取依赖(示例):
```bash
go get github.com/gin-gonic/gin
go get gorm.io/gorm gorm.io/driver/mysql
go get github.com/unrolled/secure
go get golang.org/x/crypto golang.org/x/time
go get github.com/gin-contrib/cors
go get github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
# 可选
go get github.com/swaggo/gin-swagger github.com/swaggo/files
# 开发
go install github.com/cosmtrek/air@latest
go install github.com/swaggo/swag/cmd/swag@latest
```
---
## 五、验证方式
在 soul-server 目录下执行:
```bash
go mod tidy
go build ./...
```
若通过,则当前依赖与 Go 1.25.7 兼容。若某库报错,优先升级该库至最新 minor/patch 再试。
---
## 六、小结
| 项目 | 结论 |
|------|------|
| Go 1.25.7 | 支持,所有推荐依赖均适用。 |
| 需要更换的依赖 | 无。 |
| 需要移除的依赖 | 无(按本清单与精简原则已不包含不必要项)。 |
| 建议 | 使用 `go 1.25`,定期 `go get -u ./...``go mod tidy` 保持依赖健康。 |