更新管理后台布局,优化菜单项标签,新增支付配置项。同时,调整API响应字段命名,确保一致性,提升代码可读性和维护性。
This commit is contained in:
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
soul-admin/.env.production
Normal file
2
soul-admin/.env.production
Normal 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
1
soul-admin/dist/assets/index-C8xDvmbL.css
vendored
Normal file
1
soul-admin/dist/assets/index-C8xDvmbL.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-Z7C0sgIG.css
vendored
1
soul-admin/dist/assets/index-Z7C0sgIG.css
vendored
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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
23
soul-api/.air.toml
Normal 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
12
soul-api/.env
Normal 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
12
soul-api/.env.example
Normal 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
9
soul-api/Makefile
Normal file
@@ -0,0 +1,9 @@
|
||||
# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest)
|
||||
dev:
|
||||
air
|
||||
|
||||
# 普通运行(无热重载)
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
.PHONY: dev run
|
||||
50
soul-api/cmd/server/main.go
Normal file
50
soul-api/cmd/server/main.go
Normal 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
46
soul-api/go.mod
Normal 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
116
soul-api/go.sum
Normal 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=
|
||||
42
soul-api/internal/config/config.go
Normal file
42
soul-api/internal/config/config.go
Normal 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
|
||||
}
|
||||
26
soul-api/internal/database/database.go
Normal file
26
soul-api/internal/database/database.go
Normal 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
|
||||
}
|
||||
33
soul-api/internal/handler/admin.go
Normal file
33
soul-api/internal/handler/admin.go
Normal 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})
|
||||
}
|
||||
101
soul-api/internal/handler/admin_chapters.go
Normal file
101
soul-api/internal/handler/admin_chapters.go
Normal 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})
|
||||
}
|
||||
99
soul-api/internal/handler/admin_distribution.go
Normal file
99
soul-api/internal/handler/admin_distribution.go
Normal 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) + "%"
|
||||
}
|
||||
22
soul-api/internal/handler/admin_extra.go
Normal file
22
soul-api/internal/handler/admin_extra.go
Normal 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})
|
||||
}
|
||||
119
soul-api/internal/handler/admin_withdrawals.go
Normal file
119
soul-api/internal/handler/admin_withdrawals.go
Normal 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": "操作成功"})
|
||||
}
|
||||
17
soul-api/internal/handler/auth.go
Normal file
17
soul-api/internal/handler/auth.go
Normal 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})
|
||||
}
|
||||
160
soul-api/internal/handler/book.go
Normal file
160
soul-api/internal/handler/book.go
Normal 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 维护"})
|
||||
}
|
||||
22
soul-api/internal/handler/ckb.go
Normal file
22
soul-api/internal/handler/ckb.go
Normal 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})
|
||||
}
|
||||
63
soul-api/internal/handler/config.go
Normal file
63
soul-api/internal/handler/config.go
Normal 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)
|
||||
}
|
||||
12
soul-api/internal/handler/content.go
Normal file
12
soul-api/internal/handler/content.go
Normal 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})
|
||||
}
|
||||
17
soul-api/internal/handler/cron.go
Normal file
17
soul-api/internal/handler/cron.go
Normal 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})
|
||||
}
|
||||
391
soul-api/internal/handler/db.go
Normal file
391
soul-api/internal/handler/db.go
Normal 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/外部执行"})
|
||||
}
|
||||
247
soul-api/internal/handler/db_book.go
Normal file
247
soul-api/internal/handler/db_book.go
Normal 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})
|
||||
}
|
||||
22
soul-api/internal/handler/distribution.go
Normal file
22
soul-api/internal/handler/distribution.go
Normal 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})
|
||||
}
|
||||
12
soul-api/internal/handler/documentation.go
Normal file
12
soul-api/internal/handler/documentation.go
Normal 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})
|
||||
}
|
||||
22
soul-api/internal/handler/match.go
Normal file
22
soul-api/internal/handler/match.go
Normal 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{}{}})
|
||||
}
|
||||
12
soul-api/internal/handler/menu.go
Normal file
12
soul-api/internal/handler/menu.go
Normal 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{}{}})
|
||||
}
|
||||
32
soul-api/internal/handler/miniprogram.go
Normal file
32
soul-api/internal/handler/miniprogram.go
Normal 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})
|
||||
}
|
||||
20
soul-api/internal/handler/orders.go
Normal file
20
soul-api/internal/handler/orders.go
Normal 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})
|
||||
}
|
||||
52
soul-api/internal/handler/payment.go
Normal file
52
soul-api/internal/handler/payment.go
Normal 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})
|
||||
}
|
||||
22
soul-api/internal/handler/referral.go
Normal file
22
soul-api/internal/handler/referral.go
Normal 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})
|
||||
}
|
||||
81
soul-api/internal/handler/search.go
Normal file
81
soul-api/internal/handler/search.go
Normal 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},
|
||||
})
|
||||
}
|
||||
22
soul-api/internal/handler/sync.go
Normal file
22
soul-api/internal/handler/sync.go
Normal 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})
|
||||
}
|
||||
17
soul-api/internal/handler/upload.go
Normal file
17
soul-api/internal/handler/upload.go
Normal 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})
|
||||
}
|
||||
157
soul-api/internal/handler/user.go
Normal file
157
soul-api/internal/handler/user.go
Normal 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})
|
||||
}
|
||||
12
soul-api/internal/handler/wechat.go
Normal file
12
soul-api/internal/handler/wechat.go
Normal 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})
|
||||
}
|
||||
60
soul-api/internal/handler/withdraw.go
Normal file
60
soul-api/internal/handler/withdraw.go
Normal 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": ""}})
|
||||
}
|
||||
25
soul-api/internal/middleware/admin_auth.go
Normal file
25
soul-api/internal/middleware/admin_auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
65
soul-api/internal/middleware/ratelimit.go
Normal file
65
soul-api/internal/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
25
soul-api/internal/middleware/secure.go
Normal file
25
soul-api/internal/middleware/secure.go
Normal 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()
|
||||
}
|
||||
}
|
||||
1
soul-api/internal/model/README.txt
Normal file
1
soul-api/internal/model/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。
|
||||
23
soul-api/internal/model/chapter.go
Normal file
23
soul-api/internal/model/chapter.go
Normal 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" }
|
||||
24
soul-api/internal/model/order.go
Normal file
24
soul-api/internal/model/order.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Order 对应表 orders,JSON 输出与现网接口 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" }
|
||||
19
soul-api/internal/model/referral_binding.go
Normal file
19
soul-api/internal/model/referral_binding.go
Normal 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" }
|
||||
13
soul-api/internal/model/referral_visit.go
Normal file
13
soul-api/internal/model/referral_visit.go
Normal 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" }
|
||||
35
soul-api/internal/model/system_config.go
Normal file
35
soul-api/internal/model/system_config.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigValue 存 system_config.config_value(JSON 列,可为 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_config,JSON 输出与现网 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" }
|
||||
26
soul-api/internal/model/user.go
Normal file
26
soul-api/internal/model/user.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
|
||||
// User 对应表 users,JSON 输出与现网接口 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" }
|
||||
16
soul-api/internal/model/user_track.go
Normal file
16
soul-api/internal/model/user_track.go
Normal 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" }
|
||||
17
soul-api/internal/model/withdrawal.go
Normal file
17
soul-api/internal/model/withdrawal.go
Normal 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" }
|
||||
1
soul-api/internal/repository/README.txt
Normal file
1
soul-api/internal/repository/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。
|
||||
213
soul-api/internal/router/router.go
Normal file
213
soul-api/internal/router/router.go
Normal 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
|
||||
}
|
||||
1
soul-api/internal/service/README.txt
Normal file
1
soul-api/internal/service/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。
|
||||
BIN
soul-api/server.exe
Normal file
BIN
soul-api/server.exe
Normal file
Binary file not shown.
BIN
soul-api/tmp/main.exe
Normal file
BIN
soul-api/tmp/main.exe
Normal file
Binary file not shown.
120
开发文档/2、架构/Gin技术栈-Go1.25依赖清单.md
Normal file
120
开发文档/2、架构/Gin技术栈-Go1.25依赖清单.md
Normal 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` 保持依赖健康。 |
|
||||
Reference in New Issue
Block a user