更新小程序API路径,统一为/api/miniprogram前缀,确保与后端一致性。同时,调整微信支付相关配置,增强系统的灵活性和可维护性。
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
App({
|
||||
globalData: {
|
||||
// API基础地址 - 连接真实后端
|
||||
// baseUrl: 'https://soul.quwanzhi.com',
|
||||
baseUrl: 'http://localhost:3006',
|
||||
// baseUrl: 'https://soulApi.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:3006',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
|
||||
// 小程序配置 - 真实AppID
|
||||
appId: 'wxb8bbb2b10dec74aa',
|
||||
@@ -95,7 +96,7 @@ App({
|
||||
// 同步写入 referral_code,供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code
|
||||
wx.setStorageSync('referral_code', refCode)
|
||||
|
||||
// 如果已登录,立即尝试绑定,由 /api/referral/bind 按 30 天规则决定 new / renew / takeover
|
||||
// 如果已登录,立即尝试绑定,由 /api/miniprogram/referral/bind 按 30 天规则决定 new / renew / takeover
|
||||
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
|
||||
this.bindReferralCode(refCode)
|
||||
}
|
||||
@@ -109,7 +110,7 @@ App({
|
||||
const openId = this.globalData.openId || wx.getStorageSync('openId') || ''
|
||||
const userId = this.globalData.userInfo?.id || ''
|
||||
|
||||
await this.request('/api/referral/visit', {
|
||||
await this.request('/api/miniprogram/referral/visit', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
referralCode: refCode,
|
||||
@@ -135,7 +136,7 @@ App({
|
||||
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
|
||||
|
||||
// 调用API绑定推荐关系
|
||||
const res = await this.request('/api/referral/bind', {
|
||||
const res = await this.request('/api/miniprogram/referral/bind', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
@@ -199,7 +200,7 @@ App({
|
||||
}
|
||||
|
||||
// 从服务器获取最新数据
|
||||
const res = await this.request('/api/book/all-chapters')
|
||||
const res = await this.request('/api/miniprogram/book/all-chapters')
|
||||
if (res && res.data) {
|
||||
this.globalData.bookData = res.data
|
||||
wx.setStorageSync('bookData', res.data)
|
||||
@@ -389,13 +390,19 @@ App({
|
||||
return null
|
||||
},
|
||||
|
||||
// 手机号登录
|
||||
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
|
||||
async loginWithPhone(phoneCode) {
|
||||
try {
|
||||
// 尝试API登录
|
||||
const res = await this.request('/api/wechat/phone-login', {
|
||||
const loginRes = await new Promise((resolve, reject) => {
|
||||
wx.login({ success: resolve, fail: reject })
|
||||
})
|
||||
if (!loginRes.code) {
|
||||
wx.showToast({ title: '获取登录态失败', icon: 'none' })
|
||||
return null
|
||||
}
|
||||
const res = await this.request('/api/miniprogram/phone-login', {
|
||||
method: 'POST',
|
||||
data: { code: phoneCode }
|
||||
data: { code: loginRes.code, phoneCode }
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
|
||||
@@ -65,10 +65,10 @@ Component({
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
console.log('[TabBar] 开始加载功能配置...')
|
||||
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/db/config')
|
||||
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config')
|
||||
|
||||
// app.request 的第一个参数是 url 字符串,第二个参数是 options 对象
|
||||
const res = await app.request('/api/db/config', {
|
||||
const res = await app.request('/api/miniprogram/config', {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ Page({
|
||||
// 加载书籍统计
|
||||
async loadBookStats() {
|
||||
try {
|
||||
const res = await app.request('/api/book/stats')
|
||||
const res = await app.request('/api/miniprogram/book/stats')
|
||||
if (res && res.success) {
|
||||
this.setData({
|
||||
'bookInfo.totalChapters': res.data?.totalChapters || 62,
|
||||
|
||||
@@ -59,7 +59,7 @@ Page({
|
||||
this.setData({ loading: true })
|
||||
|
||||
try {
|
||||
const res = await app.request(`/api/user/addresses?userId=${userId}`)
|
||||
const res = await app.request(`/api/miniprogram/user/addresses?userId=${userId}`)
|
||||
if (res.success && res.list) {
|
||||
this.setData({
|
||||
addressList: res.list,
|
||||
@@ -92,7 +92,7 @@ Page({
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const result = await app.request(`/api/user/addresses/${id}`, {
|
||||
const result = await app.request(`/api/miniprogram/user/addresses/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Page({
|
||||
wx.showLoading({ title: '加载中...', mask: true })
|
||||
|
||||
try {
|
||||
const res = await app.request(`/api/user/addresses/${id}`)
|
||||
const res = await app.request(`/api/miniprogram/user/addresses/${id}`)
|
||||
if (res.success && res.data) {
|
||||
const addr = res.data
|
||||
this.setData({
|
||||
@@ -160,13 +160,13 @@ Page({
|
||||
let res
|
||||
if (isEdit) {
|
||||
// 编辑模式 - PUT 请求
|
||||
res = await app.request(`/api/user/addresses/${addressId}`, {
|
||||
res = await app.request(`/api/miniprogram/user/addresses/${addressId}`, {
|
||||
method: 'PUT',
|
||||
data: addressData
|
||||
})
|
||||
} else {
|
||||
// 新增模式 - POST 请求
|
||||
res = await app.request('/api/user/addresses', {
|
||||
res = await app.request('/api/miniprogram/user/addresses', {
|
||||
method: 'POST',
|
||||
data: addressData
|
||||
})
|
||||
|
||||
@@ -157,7 +157,7 @@ Page({
|
||||
// 加载书籍数据
|
||||
async loadBookData() {
|
||||
try {
|
||||
const res = await app.request('/api/book/all-chapters')
|
||||
const res = await app.request('/api/miniprogram/book/all-chapters')
|
||||
if (res && res.data) {
|
||||
this.setData({
|
||||
bookData: res.data,
|
||||
|
||||
@@ -96,7 +96,7 @@ Page({
|
||||
// 加载匹配配置
|
||||
async loadMatchConfig() {
|
||||
try {
|
||||
const res = await app.request('/api/match/config', {
|
||||
const res = await app.request('/api/miniprogram/match/config', {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
@@ -321,7 +321,7 @@ Page({
|
||||
// 从数据库获取真实用户匹配
|
||||
let matchedUser = null
|
||||
try {
|
||||
const res = await app.request('/api/match/users', {
|
||||
const res = await app.request('/api/miniprogram/match/users', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
matchType: this.data.selectedType,
|
||||
@@ -405,7 +405,7 @@ Page({
|
||||
// 上报匹配行为
|
||||
async reportMatch(matchedUser) {
|
||||
try {
|
||||
await app.request('/api/ckb/match', {
|
||||
await app.request('/api/miniprogram/ckb/match', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
matchType: this.data.selectedType,
|
||||
@@ -519,7 +519,7 @@ Page({
|
||||
this.setData({ isJoining: true, joinError: '' })
|
||||
|
||||
try {
|
||||
const res = await app.request('/api/ckb/join', {
|
||||
const res = await app.request('/api/miniprogram/ckb/join', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type: joinType,
|
||||
|
||||
@@ -86,7 +86,7 @@ Page({
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/db/config',
|
||||
url: '/api/miniprogram/config',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
@@ -151,7 +151,7 @@ Page({
|
||||
const userInfo = app.globalData.userInfo
|
||||
if (!app.globalData.isLoggedIn || !userInfo || !userInfo.id) return
|
||||
try {
|
||||
const res = await app.request('/api/withdraw/pending-confirm?userId=' + userInfo.id)
|
||||
const res = await app.request('/api/miniprogram/withdraw/pending-confirm?userId=' + userInfo.id)
|
||||
if (res && res.success && res.data) {
|
||||
const list = (res.data.list || []).map(item => ({
|
||||
id: item.id,
|
||||
@@ -230,7 +230,7 @@ Page({
|
||||
const formatMoney = (num) => (typeof num === 'number' ? num.toFixed(2) : '0.00')
|
||||
|
||||
try {
|
||||
const res = await app.request('/api/referral/data?userId=' + userInfo.id)
|
||||
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
|
||||
if (!res || !res.success || !res.data) return
|
||||
|
||||
const d = res.data
|
||||
@@ -262,7 +262,7 @@ Page({
|
||||
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/upload',
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempAvatarUrl,
|
||||
name: 'file',
|
||||
formData: {
|
||||
@@ -298,7 +298,7 @@ Page({
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
|
||||
// 4. 同步到服务器数据库
|
||||
await app.request('/api/user/update', {
|
||||
await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: { userId: userInfo.id, avatar: avatarUrl }
|
||||
})
|
||||
@@ -328,7 +328,7 @@ Page({
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
|
||||
// 同步到服务器
|
||||
await app.request('/api/user/update', {
|
||||
await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: { userId: userInfo.id, nickname }
|
||||
})
|
||||
@@ -398,7 +398,7 @@ Page({
|
||||
|
||||
try {
|
||||
// 1. 同步到服务器
|
||||
const res = await app.request('/api/user/update', {
|
||||
const res = await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: this.data.userInfo.id,
|
||||
|
||||
@@ -169,7 +169,7 @@ Page({
|
||||
this.setData({ section })
|
||||
|
||||
// 从 API 获取内容
|
||||
const res = await app.request(`/api/book/chapter/${id}`)
|
||||
const res = await app.request(`/api/miniprogram/book/chapter/${id}`)
|
||||
|
||||
if (res && res.content) {
|
||||
const lines = res.content.split('\n').filter(line => line.trim())
|
||||
@@ -308,7 +308,7 @@ Page({
|
||||
reject(new Error('请求超时'))
|
||||
}, timeout)
|
||||
|
||||
app.request(`/api/book/chapter/${id}`)
|
||||
app.request(`/api/miniprogram/book/chapter/${id}`)
|
||||
.then(res => {
|
||||
clearTimeout(timer)
|
||||
resolve(res)
|
||||
@@ -622,7 +622,7 @@ Page({
|
||||
const userId = app.globalData.userInfo?.id
|
||||
|
||||
if (userId) {
|
||||
const checkRes = await app.request(`/api/user/purchase-status?userId=${userId}`)
|
||||
const checkRes = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
|
||||
|
||||
if (checkRes.success && checkRes.data) {
|
||||
// 更新本地购买状态
|
||||
@@ -852,7 +852,7 @@ Page({
|
||||
}
|
||||
|
||||
// 调用专门的购买状态查询接口
|
||||
const res = await app.request(`/api/user/purchase-status?userId=${userId}`)
|
||||
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 更新全局购买状态
|
||||
|
||||
@@ -95,7 +95,7 @@ Page({
|
||||
let realData = null
|
||||
try {
|
||||
// app.request 第一个参数是 URL 字符串(会自动拼接 baseUrl)
|
||||
const res = await app.request('/api/referral/data?userId=' + userInfo.id)
|
||||
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
|
||||
console.log('[Referral] API返回:', JSON.stringify(res).substring(0, 200))
|
||||
|
||||
if (res && res.success && res.data) {
|
||||
@@ -682,7 +682,7 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
const res = await app.request('/api/withdraw', {
|
||||
const res = await app.request('/api/miniprogram/withdraw', {
|
||||
method: 'POST',
|
||||
data: { userId, amount }
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ Page({
|
||||
// 加载热门章节(从服务器获取点击量高的章节)
|
||||
async loadHotChapters() {
|
||||
try {
|
||||
const res = await app.request('/api/book/hot')
|
||||
const res = await app.request('/api/miniprogram/book/hot')
|
||||
if (res && res.success && res.chapters?.length > 0) {
|
||||
this.setData({ hotChapters: res.chapters })
|
||||
}
|
||||
@@ -77,7 +77,7 @@ Page({
|
||||
this.setData({ loading: true, searched: true })
|
||||
|
||||
try {
|
||||
const res = await app.request(`/api/book/search?q=${encodeURIComponent(keyword.trim())}`)
|
||||
const res = await app.request(`/api/miniprogram/book/search?q=${encodeURIComponent(keyword.trim())}`)
|
||||
|
||||
if (res && res.success) {
|
||||
this.setData({
|
||||
|
||||
@@ -114,7 +114,7 @@ Page({
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
|
||||
await app.request('/api/user/update', {
|
||||
await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: { userId, address }
|
||||
})
|
||||
@@ -147,7 +147,7 @@ Page({
|
||||
|
||||
// 同步到服务器
|
||||
try {
|
||||
await app.request('/api/user/update', {
|
||||
await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: app.globalData.userInfo?.id,
|
||||
@@ -201,7 +201,7 @@ Page({
|
||||
|
||||
// 同步到服务器
|
||||
try {
|
||||
await app.request('/api/user/update', {
|
||||
await app.request('/api/miniprogram/user/update', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: app.globalData.userInfo?.id,
|
||||
@@ -273,7 +273,7 @@ Page({
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
|
||||
const res = await app.request('/api/user/profile', {
|
||||
const res = await app.request('/api/miniprogram/user/profile', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
@@ -313,7 +313,7 @@ Page({
|
||||
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/upload',
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempAvatarUrl,
|
||||
name: 'file',
|
||||
formData: {
|
||||
@@ -353,7 +353,7 @@ Page({
|
||||
// 4. 同步到服务器数据库
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
await app.request('/api/user/profile', {
|
||||
await app.request('/api/miniprogram/user/profile', {
|
||||
method: 'POST',
|
||||
data: { userId, nickname: nickName, avatar: avatarUrl }
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ Page({
|
||||
}
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
const res = await app.request('/api/withdraw/records?userId=' + userInfo.id)
|
||||
const res = await app.request('/api/miniprogram/withdraw/records?userId=' + userInfo.id)
|
||||
if (res && res.success && res.data && Array.isArray(res.data.list)) {
|
||||
const list = (res.data.list || []).map(item => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -22,7 +22,7 @@ class ChapterAccessManager {
|
||||
*/
|
||||
async fetchLatestConfig() {
|
||||
try {
|
||||
const res = await app.request('/api/db/config', { timeout: 3000 })
|
||||
const res = await app.request('/api/miniprogram/config', { timeout: 3000 })
|
||||
if (res.success && res.freeChapters) {
|
||||
return {
|
||||
freeChapters: res.freeChapters,
|
||||
@@ -70,7 +70,7 @@ class ChapterAccessManager {
|
||||
|
||||
// 3. 请求服务端校验是否已购买(带重试)
|
||||
const res = await this.requestWithRetry(
|
||||
`/api/user/check-purchased?userId=${encodeURIComponent(userId)}&type=section&productId=${encodeURIComponent(sectionId)}`,
|
||||
`/api/miniprogram/user/check-purchased?userId=${encodeURIComponent(userId)}&type=section&productId=${encodeURIComponent(sectionId)}`,
|
||||
{ timeout: 5000 },
|
||||
2 // 最多重试2次
|
||||
)
|
||||
@@ -145,7 +145,7 @@ class ChapterAccessManager {
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const res = await app.request(`/api/user/purchase-status?userId=${encodeURIComponent(userId)}`)
|
||||
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(userId)}`)
|
||||
|
||||
if (res.success && res.data) {
|
||||
app.globalData.hasFullBook = res.data.hasFullBook || false
|
||||
|
||||
@@ -174,7 +174,7 @@ class ReadingTracker {
|
||||
this.activeTracker.lastScrollTime = now
|
||||
|
||||
try {
|
||||
await app.request('/api/user/reading-progress', {
|
||||
await app.request('/api/miniprogram/user/reading-progress', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# 对接后端 base URL(不改 API 路径,仅改此处即可切换 Next → Gin)
|
||||
# 宝塔部署:若 API 站点开启了强制 HTTPS,这里必须用 https,否则预检请求会被重定向导致 CORS 报错
|
||||
# VITE_API_BASE_URL=http://localhost:3006
|
||||
# VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=http://soulapi.quwanzhi.com
|
||||
VITE_API_BASE_URL=https://soulapi.quwanzhi.com
|
||||
|
||||
|
||||
295
soul-admin/deploy_admin.py
Normal file
295
soul-admin/deploy_admin.py
Normal file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-admin 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
|
||||
不安装依赖、不重启、不调用宝塔 API。
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import argparse
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# 站点根目录(Nginx 等指向的目录的上一级,即 dist 的父目录)
|
||||
DEPLOY_BASE_PATH = "/www/wwwroot/soulAdmin.quwanzhi.com"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
def get_cfg():
|
||||
return {
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"base_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/"),
|
||||
"dist_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/") + "/dist",
|
||||
"dist2_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/") + "/dist2",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
|
||||
def run_build(root):
|
||||
"""执行本地 pnpm build"""
|
||||
use_shell = sys.platform == "win32"
|
||||
dist_dir = os.path.join(root, "dist")
|
||||
index_html = os.path.join(dist_dir, "index.html")
|
||||
|
||||
try:
|
||||
r = __import__("subprocess").run(
|
||||
["pnpm", "build"],
|
||||
cwd=root,
|
||||
shell=use_shell,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||
for line in (r.stdout or "").strip().split("\n")[-10:]:
|
||||
print(" " + line)
|
||||
return False
|
||||
except __import__("subprocess").TimeoutExpired:
|
||||
print(" [失败] 构建超时")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [失败] 构建异常:", str(e))
|
||||
return False
|
||||
|
||||
if not os.path.isfile(index_html):
|
||||
print(" [失败] 未找到 dist/index.html")
|
||||
return False
|
||||
print(" [成功] 构建完成")
|
||||
return True
|
||||
|
||||
|
||||
# ==================== 打包 dist 为 zip ====================
|
||||
|
||||
|
||||
def pack_dist_zip(root):
|
||||
"""将本地 dist 目录打包为 zip(解压到 dist2 后即为站点根内容)"""
|
||||
print("[2/4] 打包 dist 为 zip ...")
|
||||
dist_dir = os.path.join(root, "dist")
|
||||
if not os.path.isdir(dist_dir):
|
||||
print(" [失败] 未找到 dist 目录")
|
||||
return None
|
||||
index_html = os.path.join(dist_dir, "index.html")
|
||||
if not os.path.isfile(index_html):
|
||||
print(" [失败] 未找到 dist/index.html,请先执行 pnpm build")
|
||||
return None
|
||||
|
||||
zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for dirpath, _dirs, filenames in os.walk(dist_dir):
|
||||
for f in filenames:
|
||||
full = os.path.join(dirpath, f)
|
||||
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
|
||||
zf.write(full, arcname)
|
||||
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(" [失败] 打包异常:", str(e))
|
||||
return None
|
||||
|
||||
|
||||
# ==================== SSH 上传并解压到 dist2 ====================
|
||||
|
||||
|
||||
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
"""上传 zip 到服务器并解压到 dist2"""
|
||||
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
sys.stdout.flush()
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
key_filename=cfg["ssh_key"],
|
||||
timeout=30,
|
||||
banner_timeout=30,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
password=cfg["password"],
|
||||
timeout=30,
|
||||
banner_timeout=30,
|
||||
)
|
||||
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||
sys.stdout.flush()
|
||||
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
|
||||
sftp = client.open_sftp()
|
||||
chunk_mb = 5.0
|
||||
last_reported = [0]
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total and total > 0:
|
||||
now_mb = transferred / (1024 * 1024)
|
||||
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
|
||||
last_reported[0] = now_mb
|
||||
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
|
||||
sys.stdout.flush()
|
||||
|
||||
sftp.put(zip_path, remote_zip, callback=_progress)
|
||||
if zip_size_mb >= chunk_mb:
|
||||
print("")
|
||||
print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
sftp.close()
|
||||
dist2 = cfg["dist2_path"]
|
||||
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
|
||||
dist2,
|
||||
dist2,
|
||||
remote_zip,
|
||||
dist2,
|
||||
remote_zip,
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:500])
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||
if out:
|
||||
print(" stdout: %s" % out[:300])
|
||||
return False
|
||||
print(" [成功] 已解压到: %s" % dist2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 服务器目录切换:dist → dist1,dist2 → dist ====================
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
"""服务器上:dist→dist1,dist2→dist,删除 dist1,实现无缝切换"""
|
||||
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
base = cfg["base_path"]
|
||||
# 若当前没有 dist(首次部署),则 dist2 直接改名为 dist;若有 dist 则先备份再替换
|
||||
cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0 or "OK" not in out:
|
||||
print(" [失败] 切换失败")
|
||||
return False
|
||||
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-admin 静态站点部署(dist2 解压后目录切换,无缝更新)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||
root = script_dir
|
||||
else:
|
||||
root = os.path.dirname(script_dir)
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-admin 部署(dist/dist2 无缝切换)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
|
||||
print("=" * 60)
|
||||
|
||||
if not args.no_build:
|
||||
print("[1/4] 本地构建 pnpm build ...")
|
||||
if not run_build(root):
|
||||
return 1
|
||||
else:
|
||||
if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
|
||||
os.path.join(root, "dist", "index.html")
|
||||
):
|
||||
print("[错误] 未找到 dist/index.html,请先执行 pnpm build 或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过本地构建")
|
||||
|
||||
zip_path = pack_dist_zip(root)
|
||||
if not zip_path:
|
||||
return 1
|
||||
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
return 1
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
if not remote_swap_dist(cfg):
|
||||
return 1
|
||||
print("")
|
||||
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
775
soul-admin/devlop.py
Normal file
775
soul-admin/devlop.py
Normal file
@@ -0,0 +1,775 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
import tarfile
|
||||
import subprocess
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
print("错误: 请先安装 requests")
|
||||
print(" pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值(需与 Nginx proxy_pass、ecosystem.config.cjs 一致)
|
||||
DEPLOY_PM2_APP = "soul"
|
||||
DEFAULT_DEPLOY_PORT = 3006
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul"
|
||||
DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
|
||||
# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
def get_cfg():
|
||||
"""获取基础部署配置(deploy 模式与 devlop 共用 SSH/宝塔)"""
|
||||
return {
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"panel_url": os.environ.get("BAOTA_PANEL_URL", "https://43.139.27.93:9988"),
|
||||
"api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"),
|
||||
"pm2_name": os.environ.get("DEPLOY_PM2_APP", DEPLOY_PM2_APP),
|
||||
"site_url": os.environ.get("DEPLOY_SITE_URL", DEPLOY_SITE_URL),
|
||||
"port": int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))),
|
||||
"node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"),
|
||||
"node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"),
|
||||
}
|
||||
|
||||
|
||||
def get_cfg_devlop():
|
||||
"""devlop 模式配置:在基础配置上增加 base_path / dist / dist2。
|
||||
实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path,
|
||||
否则会从错误目录启动导致 .next/static 等静态资源 404。"""
|
||||
cfg = get_cfg().copy()
|
||||
cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", DEPLOY_PROJECT_PATH)
|
||||
cfg["dist_path"] = cfg["base_path"] + "/dist"
|
||||
cfg["dist2_path"] = cfg["base_path"] + "/dist2"
|
||||
return cfg
|
||||
|
||||
|
||||
# ==================== 宝塔 API ====================
|
||||
|
||||
def _get_sign(api_key):
|
||||
now_time = int(time.time())
|
||||
sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest()
|
||||
request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
|
||||
return now_time, request_token
|
||||
|
||||
|
||||
def _baota_request(panel_url, api_key, path, data=None):
|
||||
req_time, req_token = _get_sign(api_key)
|
||||
payload = {"request_time": req_time, "request_token": req_token}
|
||||
if data:
|
||||
payload.update(data)
|
||||
url = panel_url.rstrip("/") + "/" + path.lstrip("/")
|
||||
try:
|
||||
r = requests.post(url, data=payload, verify=False, timeout=30)
|
||||
return r.json() if r.text else {}
|
||||
except Exception as e:
|
||||
print(" API 请求失败: %s" % str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_node_project_list(panel_url, api_key):
|
||||
for path in ["/project/nodejs/get_project_list", "/plugin?action=a&name=nodejs&s=get_project_list"]:
|
||||
result = _baota_request(panel_url, api_key, path)
|
||||
if result and (result.get("status") is True or "data" in result):
|
||||
return result.get("data", [])
|
||||
return None
|
||||
|
||||
|
||||
def get_node_project_status(panel_url, api_key, pm2_name):
|
||||
projects = get_node_project_list(panel_url, api_key)
|
||||
if projects:
|
||||
for p in projects:
|
||||
if p.get("name") == pm2_name:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def start_node_project(panel_url, api_key, pm2_name):
|
||||
for path in ["/project/nodejs/start_project", "/plugin?action=a&name=nodejs&s=start_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 启动成功: %s" % pm2_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def stop_node_project(panel_url, api_key, pm2_name):
|
||||
for path in ["/project/nodejs/stop_project", "/plugin?action=a&name=nodejs&s=stop_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 停止成功: %s" % pm2_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def restart_node_project(panel_url, api_key, pm2_name):
|
||||
project_status = get_node_project_status(panel_url, api_key, pm2_name)
|
||||
if project_status:
|
||||
print(" 项目状态: %s" % project_status.get("status", "未知"))
|
||||
for path in ["/project/nodejs/restart_project", "/plugin?action=a&name=nodejs&s=restart_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 重启成功: %s" % pm2_name)
|
||||
return True
|
||||
if result and "msg" in result:
|
||||
print(" API 返回: %s" % result.get("msg"))
|
||||
print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确")
|
||||
return False
|
||||
|
||||
|
||||
def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=None, node_path=None):
|
||||
if port is None:
|
||||
port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||
port_env = "PORT=%d " % port
|
||||
run_cmd = port_env + ("%s/node server.js" % node_path if node_path else "node server.js")
|
||||
payload = {"name": pm2_name, "path": project_path, "run_cmd": run_cmd, "port": str(port)}
|
||||
for path in ["/project/nodejs/add_project", "/plugin?action=a&name=nodejs&s=add_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, payload)
|
||||
if result and result.get("status") is True:
|
||||
print(" [成功] 项目配置已更新: %s" % pm2_name)
|
||||
return True
|
||||
if result and "msg" in result:
|
||||
print(" API 返回: %s" % result.get("msg"))
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
def run_build(root):
|
||||
"""执行本地 pnpm build"""
|
||||
use_shell = sys.platform == "win32"
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
server_js = os.path.join(standalone, "server.js")
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["pnpm", "build"],
|
||||
cwd=root,
|
||||
shell=use_shell,
|
||||
timeout=600,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
stdout_text = r.stdout or ""
|
||||
stderr_text = r.stderr or ""
|
||||
combined = stdout_text + stderr_text
|
||||
is_windows_symlink_error = (
|
||||
sys.platform == "win32"
|
||||
and r.returncode != 0
|
||||
and ("EPERM" in combined or "symlink" in combined.lower() or "operation not permitted" in combined.lower() or "errno: -4048" in combined)
|
||||
)
|
||||
|
||||
if r.returncode != 0:
|
||||
if is_windows_symlink_error:
|
||||
print(" [警告] Windows 符号链接权限错误(EPERM)")
|
||||
print(" 解决方案:开启开发者模式 / 以管理员运行 / 或使用 --no-build")
|
||||
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||
print(" [成功] standalone 输出可用,继续部署")
|
||||
return True
|
||||
return False
|
||||
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||
for line in (stdout_text.strip().split("\n") or [])[-10:]:
|
||||
print(" " + line)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" [失败] 构建超时(超过10分钟)")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [失败] 构建异常:", str(e))
|
||||
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||
print(" [提示] 可尝试使用 --no-build 跳过构建")
|
||||
return False
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isfile(server_js):
|
||||
print(" [失败] 未找到 .next/standalone 或 server.js")
|
||||
return False
|
||||
print(" [成功] 构建完成")
|
||||
return True
|
||||
|
||||
|
||||
def clean_standalone_before_build(root, retries=3, delay=2):
|
||||
"""构建前删除 .next/standalone,避免 Windows EBUSY"""
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
if not os.path.isdir(standalone):
|
||||
return True
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
shutil.rmtree(standalone)
|
||||
print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
if attempt < retries:
|
||||
print(" [清理] 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries))
|
||||
time.sleep(delay)
|
||||
else:
|
||||
print(" [失败] 无法删除 .next/standalone,可改用 --no-build")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 打包(deploy 模式:tar.gz) ====================
|
||||
|
||||
def _copy_with_dereference(src, dst):
|
||||
if os.path.islink(src):
|
||||
link_target = os.readlink(src)
|
||||
real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target)
|
||||
if os.path.exists(real_path):
|
||||
if os.path.isdir(real_path):
|
||||
shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(real_path, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst, follow_symlinks=False)
|
||||
elif os.path.isdir(src):
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def pack_standalone_tar(root):
|
||||
"""打包 standalone 为 tar.gz(deploy 模式用)"""
|
||||
print("[2/4] 打包 standalone ...")
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
static_src = os.path.join(root, ".next", "static")
|
||||
public_src = os.path.join(root, "public")
|
||||
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||
return None
|
||||
chunks_dir = os.path.join(static_src, "chunks")
|
||||
if not os.path.isdir(chunks_dir):
|
||||
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||
return None
|
||||
|
||||
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
||||
try:
|
||||
for name in os.listdir(standalone):
|
||||
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||
node_modules_dst = os.path.join(staging, "node_modules")
|
||||
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||
if os.path.isdir(pnpm_dir):
|
||||
for dep in ["styled-jsx"]:
|
||||
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||
if not os.path.exists(dep_in_root):
|
||||
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||
if pnpm_pkg.startswith(dep + "@"):
|
||||
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||
if os.path.isdir(src_dep):
|
||||
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||
break
|
||||
static_dst = os.path.join(staging, ".next", "static")
|
||||
if os.path.exists(static_dst):
|
||||
shutil.rmtree(static_dst)
|
||||
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
||||
shutil.copytree(static_src, static_dst)
|
||||
# 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
|
||||
next_root = os.path.join(root, ".next")
|
||||
next_staging = os.path.join(staging, ".next")
|
||||
index_files = [
|
||||
"BUILD_ID",
|
||||
"build-manifest.json",
|
||||
"app-path-routes-manifest.json",
|
||||
"routes-manifest.json",
|
||||
"prerender-manifest.json",
|
||||
"required-server-files.json",
|
||||
"fallback-build-manifest.json",
|
||||
]
|
||||
for name in index_files:
|
||||
src = os.path.join(next_root, name)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(next_staging, name))
|
||||
print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
|
||||
if os.path.isdir(public_src):
|
||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||
if os.path.isfile(ecosystem_src):
|
||||
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||
pkg_json = os.path.join(staging, "package.json")
|
||||
if os.path.isfile(pkg_json):
|
||||
try:
|
||||
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
tf.add(os.path.join(staging, name), arcname=name)
|
||||
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
|
||||
return tarball
|
||||
except Exception as e:
|
||||
print(" [失败] 打包异常:", str(e))
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# ==================== Node 环境检查 & SSH 上传(deploy 模式) ====================
|
||||
|
||||
def check_node_environments(cfg):
|
||||
print("[检查] Node 环境 ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key"):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10)
|
||||
print(" 默认 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "未找到"))
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5)
|
||||
print(" 配置 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "不可用"))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [警告] %s" % str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path):
|
||||
"""SSH 上传 tar.gz 并解压到 project_path(deploy 模式)"""
|
||||
print("[3/4] SSH 上传并解压 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
sftp = client.open_sftp()
|
||||
remote_tar = "/tmp/soul_deploy.tar.gz"
|
||||
remote_script = "/tmp/soul_deploy_extract.sh"
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
project_path = cfg["project_path"]
|
||||
script_content = """#!/bin/bash
|
||||
export PATH=%s:$PATH
|
||||
cd %s
|
||||
rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null
|
||||
tar -xzf %s
|
||||
rm -f %s
|
||||
echo OK
|
||||
""" % (node_path, project_path, remote_tar, remote_tar)
|
||||
with sftp.open(remote_script, "w") as f:
|
||||
f.write(script_content)
|
||||
sftp.close()
|
||||
client.exec_command("chmod +x %s" % remote_script, timeout=10)
|
||||
stdin, stdout, stderr = client.exec_command("bash %s" % remote_script, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码:", exit_status)
|
||||
return False
|
||||
print(" [成功] 解压完成: %s" % project_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def deploy_via_baota_api(cfg):
|
||||
"""宝塔 API 重启 Node 项目(deploy 模式)"""
|
||||
print("[4/4] 宝塔 API 管理 Node 项目 ...")
|
||||
panel_url, api_key, pm2_name = cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]
|
||||
project_path = cfg["project_path"]
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
port = cfg["port"]
|
||||
|
||||
if not get_node_project_status(panel_url, api_key, pm2_name):
|
||||
add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path)
|
||||
stop_node_project(panel_url, api_key, pm2_name)
|
||||
time.sleep(2)
|
||||
ok = restart_node_project(panel_url, api_key, pm2_name)
|
||||
if not ok:
|
||||
ok = start_node_project(panel_url, api_key, pm2_name)
|
||||
if not ok:
|
||||
print(" 请到宝塔 Node 项目手动重启 %s,路径: %s" % (pm2_name, project_path))
|
||||
return ok
|
||||
|
||||
|
||||
# ==================== 打包(devlop 模式:zip) ====================
|
||||
|
||||
ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"}
|
||||
ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
|
||||
ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
|
||||
|
||||
|
||||
def _should_exclude_from_zip(arcname, is_file=True):
|
||||
parts = arcname.replace("\\", "/").split("/")
|
||||
for part in parts:
|
||||
if part in ZIP_EXCLUDE_DIRS:
|
||||
return True
|
||||
if is_file and parts:
|
||||
name = parts[-1]
|
||||
if name in ZIP_EXCLUDE_FILE_NAMES or any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pack_standalone_zip(root):
|
||||
"""打包 standalone 为 zip(devlop 模式用)"""
|
||||
print("[2/7] 打包 standalone 为 zip ...")
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
static_src = os.path.join(root, ".next", "static")
|
||||
public_src = os.path.join(root, "public")
|
||||
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||
return None
|
||||
chunks_dir = os.path.join(static_src, "chunks")
|
||||
if not os.path.isdir(chunks_dir):
|
||||
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||
return None
|
||||
|
||||
staging = tempfile.mkdtemp(prefix="soul_devlop_")
|
||||
try:
|
||||
for name in os.listdir(standalone):
|
||||
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||
node_modules_dst = os.path.join(staging, "node_modules")
|
||||
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||
if os.path.isdir(pnpm_dir):
|
||||
for dep in ["styled-jsx"]:
|
||||
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||
if not os.path.exists(dep_in_root):
|
||||
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||
if pnpm_pkg.startswith(dep + "@"):
|
||||
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||
if os.path.isdir(src_dep):
|
||||
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||
break
|
||||
os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
|
||||
shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
|
||||
# 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
|
||||
next_root = os.path.join(root, ".next")
|
||||
next_staging = os.path.join(staging, ".next")
|
||||
index_files = [
|
||||
"BUILD_ID",
|
||||
"build-manifest.json",
|
||||
"app-path-routes-manifest.json",
|
||||
"routes-manifest.json",
|
||||
"prerender-manifest.json",
|
||||
"required-server-files.json",
|
||||
"fallback-build-manifest.json",
|
||||
]
|
||||
for name in index_files:
|
||||
src = os.path.join(next_root, name)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(next_staging, name))
|
||||
print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
|
||||
if os.path.isdir(public_src):
|
||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||
if os.path.isfile(ecosystem_src):
|
||||
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||
pkg_json = os.path.join(staging, "package.json")
|
||||
if os.path.isfile(pkg_json):
|
||||
try:
|
||||
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
server_js = os.path.join(staging, "server.js")
|
||||
if os.path.isfile(server_js):
|
||||
try:
|
||||
deploy_port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||
with open(server_js, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
if "|| 3000" in c:
|
||||
with open(server_js, "w", encoding="utf-8") as f:
|
||||
f.write(c.replace("|| 3000", "|| %d" % deploy_port))
|
||||
except Exception:
|
||||
pass
|
||||
zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip")
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name in os.listdir(staging):
|
||||
path = os.path.join(staging, name)
|
||||
if os.path.isfile(path):
|
||||
if not _should_exclude_from_zip(name):
|
||||
zf.write(path, name)
|
||||
else:
|
||||
for dirpath, dirs, filenames in os.walk(path):
|
||||
dirs[:] = [d for d in dirs if not _should_exclude_from_zip(os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False)]
|
||||
for f in filenames:
|
||||
full = os.path.join(dirpath, f)
|
||||
arcname = os.path.join(name, os.path.relpath(full, path))
|
||||
if not _should_exclude_from_zip(arcname):
|
||||
zf.write(full, arcname)
|
||||
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(" [失败] 打包异常:", str(e))
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
"""上传 zip 并解压到 dist2(devlop 模式)"""
|
||||
print("[3/7] SSH 上传 zip 并解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
sys.stdout.flush()
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30, banner_timeout=30)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30, banner_timeout=30)
|
||||
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||
sys.stdout.flush()
|
||||
remote_zip = cfg["base_path"].rstrip("/") + "/soul_devlop.zip"
|
||||
sftp = client.open_sftp()
|
||||
# 上传进度:每 5MB 打印一次
|
||||
chunk_mb = 5.0
|
||||
last_reported = [0]
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total and total > 0:
|
||||
now_mb = transferred / (1024 * 1024)
|
||||
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
|
||||
last_reported[0] = now_mb
|
||||
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
|
||||
sys.stdout.flush()
|
||||
|
||||
sftp.put(zip_path, remote_zip, callback=_progress)
|
||||
if zip_size_mb >= chunk_mb:
|
||||
print("")
|
||||
print(" [OK] zip 已上传,正在服务器解压(约 1–3 分钟)...")
|
||||
sys.stdout.flush()
|
||||
sftp.close()
|
||||
dist2 = cfg["dist2_path"]
|
||||
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (dist2, dist2, remote_zip, dist2, remote_zip)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:500])
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||
if out:
|
||||
print(" stdout: %s" % out[:300])
|
||||
return False
|
||||
print(" [成功] 已解压到: %s" % dist2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def run_pnpm_install_in_dist2(cfg):
|
||||
"""服务器 dist2 内执行 pnpm install,阻塞等待完成后再返回(改目录前必须完成)"""
|
||||
print("[4/7] 服务器 dist2 内执行 pnpm install(等待完成后再切换目录)...")
|
||||
sys.stdout.flush()
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10)
|
||||
pnpm_path = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if not pnpm_path:
|
||||
return False, "未找到 pnpm,请服务器安装: npm install -g pnpm"
|
||||
cmd = "bash -lc 'cd %s && %s install'" % (cfg["dist2_path"], pnpm_path)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0:
|
||||
return False, "pnpm install 失败\n" + (err or out)
|
||||
print(" [成功] dist2 内 pnpm install 已执行完成,可安全切换目录")
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def remote_swap_dist_and_restart(cfg):
|
||||
"""暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)"""
|
||||
print("[5/7] 宝塔 API 暂停 Node 项目 ...")
|
||||
stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
|
||||
time.sleep(2)
|
||||
print("[6/7] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % cfg["base_path"]
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0 or "OK" not in out:
|
||||
print(" [失败] 切换失败")
|
||||
return False
|
||||
print(" [成功] 新版本位于 %s" % cfg["dist_path"])
|
||||
finally:
|
||||
client.close()
|
||||
# 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404
|
||||
print("[7/7] 更新宝塔 Node 项目路径并重启 ...")
|
||||
add_or_update_node_project(
|
||||
cfg["panel_url"], cfg["api_key"], cfg["pm2_name"],
|
||||
cfg["dist_path"], # 使用 dist_path,不是 project_path
|
||||
port=cfg["port"],
|
||||
node_path=cfg.get("node_path"),
|
||||
)
|
||||
if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
|
||||
print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"]))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Soul 创业派对 - 统一部署脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
|
||||
parser.add_argument("--mode", choices=["devlop", "deploy"], default="devlop", help="devlop=dist切换(默认), deploy=直接覆盖")
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地构建")
|
||||
parser.add_argument("--no-upload", action="store_true", help="仅 deploy 模式:跳过 SSH 上传")
|
||||
parser.add_argument("--no-api", action="store_true", help="仅 deploy 模式:上传后不调宝塔 API")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# 支持 devlop.py 在项目根或 scripts/ 下:以含 package.json 的目录为 root
|
||||
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||
root = script_dir
|
||||
else:
|
||||
root = os.path.dirname(script_dir)
|
||||
|
||||
if args.mode == "devlop":
|
||||
cfg = get_cfg_devlop()
|
||||
print("=" * 60)
|
||||
print(" Soul 自动部署(dist 切换)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 目录: %s Node: %s" % (cfg["user"], cfg["host"], cfg["base_path"], cfg["pm2_name"]))
|
||||
print("=" * 60)
|
||||
if not args.no_build:
|
||||
print("[1/7] 本地构建 pnpm build ...")
|
||||
if sys.platform == "win32" and not clean_standalone_before_build(root):
|
||||
return 1
|
||||
if not run_build(root):
|
||||
return 1
|
||||
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||
print("[错误] 未找到 .next/standalone/server.js")
|
||||
return 1
|
||||
else:
|
||||
print("[1/7] 跳过本地构建")
|
||||
zip_path = pack_standalone_zip(root)
|
||||
if not zip_path:
|
||||
return 1
|
||||
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
return 1
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
# 必须在 dist2 内 pnpm install 执行完成后再切换目录
|
||||
ok, err = run_pnpm_install_in_dist2(cfg)
|
||||
if not ok:
|
||||
print(" [失败] %s" % (err or "pnpm install 失败"))
|
||||
return 1
|
||||
# install 已完成,再执行 dist→dist1、dist2→dist 切换
|
||||
if not remote_swap_dist_and_restart(cfg):
|
||||
return 1
|
||||
print("")
|
||||
print(" 部署完成!运行目录: %s" % cfg["dist_path"])
|
||||
return 0
|
||||
|
||||
# deploy 模式
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" Soul 一键部署(直接覆盖)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 项目路径: %s PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"]))
|
||||
print("=" * 60)
|
||||
if not args.no_upload:
|
||||
check_node_environments(cfg)
|
||||
if not args.no_build:
|
||||
print("[1/4] 本地构建 ...")
|
||||
if not run_build(root):
|
||||
return 1
|
||||
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||
print("[错误] 未找到 .next/standalone/server.js")
|
||||
return 1
|
||||
else:
|
||||
print("[1/4] 跳过本地构建")
|
||||
tarball = pack_standalone_tar(root)
|
||||
if not tarball:
|
||||
return 1
|
||||
if not args.no_upload:
|
||||
if not upload_and_extract(cfg, tarball):
|
||||
return 1
|
||||
try:
|
||||
os.remove(tarball)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print(" 压缩包: %s" % tarball)
|
||||
if not args.no_api and not args.no_upload:
|
||||
deploy_via_baota_api(cfg)
|
||||
print("")
|
||||
print(" 部署完成!站点: %s" % cfg["site_url"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -4,10 +4,13 @@
|
||||
* 无缝切换:仅修改 VITE_API_BASE_URL 即可切换后端
|
||||
*/
|
||||
|
||||
/** 未设置环境变量时使用的默认 API 地址(零配置部署) */
|
||||
const DEFAULT_API_BASE = 'https://soulapi.quwanzhi.com'
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
const url = import.meta.env.VITE_API_BASE_URL
|
||||
if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '')
|
||||
return ''
|
||||
return DEFAULT_API_BASE
|
||||
}
|
||||
|
||||
/** 请求完整 URL:baseUrl + path,path 必须与现网一致(如 /api/orders) */
|
||||
|
||||
@@ -135,7 +135,7 @@ export function MatchPage() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>(
|
||||
'/api/db/config?key=match_config',
|
||||
'/api/db/config/full?key=match_config',
|
||||
)
|
||||
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
|
||||
if (c) setConfig({ ...DEFAULT_CONFIG, ...c })
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ReferralSettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
get<{ success?: boolean; data?: ReferralConfig }>('/api/db/config?key=referral_config')
|
||||
get<{ success?: boolean; data?: ReferralConfig }>('/api/db/config/full?key=referral_config')
|
||||
.then((data) => {
|
||||
const c = (data as { data?: ReferralConfig; config?: ReferralConfig })?.data ?? (data as { config?: ReferralConfig })?.config
|
||||
if (c) {
|
||||
|
||||
@@ -157,7 +157,7 @@ export function SettingsPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [configRes, appConfigRes] = await Promise.all([
|
||||
get<{ success?: boolean; data?: unknown } | Record<string, unknown>>('/api/db/config'),
|
||||
get<{ success?: boolean; data?: unknown } | Record<string, unknown>>('/api/db/config/full'),
|
||||
get<Record<string, unknown>>('/api/config').catch(() => null),
|
||||
])
|
||||
let parsed = parseConfigResponse(
|
||||
@@ -220,7 +220,7 @@ export function SettingsPage() {
|
||||
value: featureConfig,
|
||||
description: '功能开关配置',
|
||||
})
|
||||
const verifyRes = await get<{ features?: FeatureConfig }>('/api/db/config').catch(() => ({}))
|
||||
const verifyRes = await get<{ features?: FeatureConfig }>('/api/db/config/full').catch(() => ({}))
|
||||
const verifyData = Array.isArray((verifyRes as { data?: unknown })?.data)
|
||||
? mergeFromConfigList((verifyRes as { data: unknown[] }).data)
|
||||
: parseConfigResponse((verifyRes as { data?: unknown })?.data ?? verifyRes)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 服务
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
# 版本号(打包 zip 前改这里,上传后访问 /health 可看到)
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据air库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
|
||||
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
@@ -10,3 +12,19 @@ DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/sou
|
||||
|
||||
# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
|
||||
|
||||
# 微信转账配置(API v3)
|
||||
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
WECHAT_KEY_PATH=certs/apiclient_key.pem
|
||||
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,32 @@
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
|
||||
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
|
||||
|
||||
# 微信转账配置(API v3)
|
||||
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
WECHAT_KEY_PATH=certs/apiclient_key.pem
|
||||
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
|
||||
|
||||
# 可选:管理端鉴权密钥(若用 JWT)
|
||||
# JWT_SECRET=your-secret
|
||||
|
||||
# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# 可选:CORS 允许的源(逗号分隔)。未设置时默认含 localhost:5174 与 soul.quwanzhi.com
|
||||
# 宝塔部署时若前端在别的域名,在此追加,例如:
|
||||
# CORS_ORIGINS=http://localhost:5174,https://soul.quwanzhi.com,https://admin.quwanzhi.com
|
||||
|
||||
BIN
soul-api/__pycache__/devlop.cpython-311.pyc
Normal file
BIN
soul-api/__pycache__/devlop.cpython-311.pyc
Normal file
Binary file not shown.
25
soul-api/certs/apiclient_cert.pem
Normal file
25
soul-api/certs/apiclient_cert.pem
Normal file
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEKzCCAxOgAwIBAgIUSh22LNXJvgtvxRwwYh1vmWhudcUwDQYJKoZIhvcNAQEL
|
||||
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||
Q0EwHhcNMjYwMTIyMDgzMzQ2WhcNMzEwMTIxMDgzMzQ2WjCBhDETMBEGA1UEAwwK
|
||||
MTMxODU5MjUwMTEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL
|
||||
DCfms4nlt57luILljaHoi6XnvZHnu5zmioDmnK/mnInpmZDlhazlj7gxCzAJBgNV
|
||||
BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAOn4ggY2z0VowJyd1ml7vlry47+qgqMxgLqHAKzaOuETI/8lDRWd
|
||||
LDfOgdVBtZNJJWF9Dk313k9UjmospjufthZ9QdTHFdK+76dnHws19ZMEaGIEJC3j
|
||||
xr5fI9SJqLXq8KmxogHSHss7Nc4e5nAvVb7cgqp8kjvNOPoJxrpKH8KFtfSOKOs1
|
||||
BxQdkwyhBZ70O9gbh7vEZM3k/zN3JsZfqssSTcKQm6u4fszPhbVeYPbZvgD6UN8B
|
||||
H465/PZqS2UwbjrPj6v6SkJgl77xqcXAhHWxISUD6NWgJaU58Idtm2M+5C0vi68u
|
||||
WcUmosOXeOHxC3IQTTlFYnqjThdvJt+qifsCAwEAAaOBuTCBtjAJBgNVHRMEAjAA
|
||||
MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2
|
||||
Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD
|
||||
MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC
|
||||
MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQCD
|
||||
nXigQonbIBZp1EdNqd1zR9alTB0KL3Z7KRxGXogUSSn/F4FGcXxvYKeOIJNg5g89
|
||||
EDsopqyzwG999lIG+D34lyabbh/j7M7JegAdCAr06X7cBxIF+ujOecotesF/dtl/
|
||||
5hWXEU3yVZSwzjvOkMAL4xnXBwIZeXQJ8fD6vLZRsRTXfm7qi88MSuWWLuB+5X2l
|
||||
CwS7e6Zu2kgL+U2YeA9cu7/l5zL1wfQqjlk1PTMwKAstvSNzamnpLAzhJ8U5g7lh
|
||||
lF9Pbbbs5Hq6VblRqCUyMDATqhqKQTAeXn3soQodHqxLw8MeL7QICQGQxBxFmItj
|
||||
TwZDp4hd2oka3oS1VsV0
|
||||
-----END CERTIFICATE-----
|
||||
28
soul-api/certs/apiclient_key.pem
Normal file
28
soul-api/certs/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDp+IIGNs9FaMCc
|
||||
ndZpe75a8uO/qoKjMYC6hwCs2jrhEyP/JQ0VnSw3zoHVQbWTSSVhfQ5N9d5PVI5q
|
||||
LKY7n7YWfUHUxxXSvu+nZx8LNfWTBGhiBCQt48a+XyPUiai16vCpsaIB0h7LOzXO
|
||||
HuZwL1W+3IKqfJI7zTj6Cca6Sh/ChbX0jijrNQcUHZMMoQWe9DvYG4e7xGTN5P8z
|
||||
dybGX6rLEk3CkJuruH7Mz4W1XmD22b4A+lDfAR+Oufz2aktlMG46z4+r+kpCYJe+
|
||||
8anFwIR1sSElA+jVoCWlOfCHbZtjPuQtL4uvLlnFJqLDl3jh8QtyEE05RWJ6o04X
|
||||
bybfqon7AgMBAAECggEAbi3WnTKGXPs9aQNzCu148L9cvM+BAXS4WB5nFP8XpxIq
|
||||
a2Z5SOpg/k7DGTf+V8OkVMpdSB02eUkqX5lzFrTZPLHzpE20WzALD1wiZFcetALp
|
||||
XO7yUqHm35NR/i5tQm3Gs0KxNgZK9g2GAvDON5oy2NRivAI5ouu7nxOnf+aUGjeS
|
||||
vAgfuP8O0CADFIyAoUeo9ZpPhMTehfSBUzPWMdXk2UAeoJQR8tp4t8Uh3AMPO/oF
|
||||
ZLo+l9dEbK3iojCjzkRXvMznx0A8Eo1Zns/2A8jG6g/QIz8ZZLmAP7cgoGGimj+y
|
||||
lbawi933yLMtGq+UlO4Xydk5LX1B8YWh6U2IsIAsYQKBgQD2cTs5B91Jr544WmKf
|
||||
dAZRD62spomnGmwC2DSQa807/W7QbwhCUCB/6UmwjX8ev4aw8ypi7Bsj3Fp26QCD
|
||||
mI75rJozReiCXvOggPi8gy4eaodsfZiplfOV5Eb0SNFkYcrvDMzj6hu/FvQwMTc7
|
||||
2X9lTjB6cZgQg2j8H3YX2YIqKQKBgQDzC3RI80u68avfLeAw/6TJV/jBIJLjs64D
|
||||
aN7vsY1zPWn03i+Wma/Bjbh8JBk69St0t/ILS7jn8ESN1RzizODYd6yFn56345zo
|
||||
zrTzZoQK3+xjMDnrdEYCww+u47pmhTVGDqxcy4nbHEN8sVw/DX+P4Ho3wd3u+8Kp
|
||||
TqCAXdQfgwKBgQDQSEDSYYgwB8JERHfH5gqUphiVq6b5WQZinRJH4SRzCC2I8d5c
|
||||
FVZyZNuH4P7IIP0YPlvbgUsq0siOaTyq+9wSvkMRBIuO6+siAv62bHQk9sn/8mJ9
|
||||
KaPWUjl5qrV2DoSx5vKfybOrnB3DQUU6Swc1upCUW782bankND7dx1IQiQKBgQDb
|
||||
ogY7xmExVyPSU0q9/MeVjAInxJ/5VW5zdlnAkdsZwO33crHejpPdfYyx4o1KUjQr
|
||||
De+VdaBrOR06btPjwPGPrNYjCtQLqY0qdWHgc0vv59te5z3wIOsDo/KQQQs5ijdS
|
||||
UABC+0xgzXHPRRfvgus7wcewi2lbhferuHoihqgisQKBgQDJBBIJsqtdbQs8IN3p
|
||||
2uEIswKgGvUTPScrrcXNH2Jox7XYIZ8GtPhspWqrudKTPdXZwVKR9wXTGc2cBZm8
|
||||
mKB5oE+cQ/a+Ub6QTZwL/vj+y8ogUvPKI7hnNaV+AFNMrwXopAmvLiAvPRuD9mIx
|
||||
RQ27dKDYfWqBlj4ssiBPeVVVWw==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/router"
|
||||
"soul-api/internal/wechat"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -22,6 +23,12 @@ func main() {
|
||||
if err := database.Init(cfg.DBDSN); err != nil {
|
||||
log.Fatal("database: ", err)
|
||||
}
|
||||
if err := wechat.Init(cfg); err != nil {
|
||||
log.Fatal("wechat: ", err)
|
||||
}
|
||||
if err := wechat.InitTransfer(cfg); err != nil {
|
||||
log.Fatal("wechat transfer: ", err)
|
||||
}
|
||||
|
||||
r := router.Setup(cfg)
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-api Go 项目一键部署到宝塔
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/自营/soul-api
|
||||
- 重启服务(nohup 或跳过)
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -16,6 +23,7 @@ import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
@@ -24,19 +32,40 @@ except ImportError:
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import requests
|
||||
try:
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul-api"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
# 宝塔 API 密钥(写死,用于部署后重启 Go 项目)
|
||||
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||
|
||||
|
||||
def get_cfg():
|
||||
host = os.environ.get("DEPLOY_HOST", "43.139.27.93")
|
||||
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
|
||||
if not bt_url:
|
||||
bt_url = "https://%s:9988" % host
|
||||
return {
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"host": host,
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
|
||||
}
|
||||
|
||||
|
||||
@@ -50,12 +79,14 @@ def run_build(root):
|
||||
env["GOOS"] = "linux"
|
||||
env["GOARCH"] = "amd64"
|
||||
env["CGO_ENABLED"] = "0"
|
||||
# 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path"
|
||||
cmd = ["go", "build", "-o", "soul-api", "./cmd/server"]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["go", "build", "-o", "soul-api", "./cmd/server"],
|
||||
cmd,
|
||||
cwd=root,
|
||||
env=env,
|
||||
shell=(sys.platform == "win32"),
|
||||
shell=False,
|
||||
timeout=120,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -116,10 +147,64 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# ==================== 宝塔 API 重启 ====================
|
||||
|
||||
|
||||
def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。pip install requests")
|
||||
return False
|
||||
try:
|
||||
req_time = int(time.time())
|
||||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||
# 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准
|
||||
base = url.rstrip("/")
|
||||
params = {"request_time": req_time, "request_token": req_token}
|
||||
# 常见形式:/plugin?name=go_project,POST 带 action、project_name
|
||||
for action in ("stop_go_project", "start_go_project"):
|
||||
data = dict(params)
|
||||
data["action"] = action
|
||||
data["project_name"] = name
|
||||
r = requests.post(
|
||||
base + "/plugin?name=go_project",
|
||||
data=data,
|
||||
timeout=15,
|
||||
verify=False,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if action == "stop_go_project":
|
||||
time.sleep(2)
|
||||
if j.get("status") is False and j.get("msg"):
|
||||
print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
|
||||
# 再调一次 start 确保启动
|
||||
data = dict(params)
|
||||
data["action"] = "start_go_project"
|
||||
data["project_name"] = name
|
||||
r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
|
||||
if r.status_code == 200:
|
||||
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if j.get("status") is True:
|
||||
print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [宝塔API 失败] %s" % str(e))
|
||||
return False
|
||||
|
||||
|
||||
# ==================== SSH 上传 ====================
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path, no_restart=False):
|
||||
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
|
||||
"""上传 tar.gz 到服务器并解压、重启"""
|
||||
print("[3/4] SSH 上传并解压 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
@@ -159,18 +244,27 @@ def upload_and_extract(cfg, tarball_path, no_restart=False):
|
||||
print(" [成功] 已解压到: %s" % project_path)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soul-api 服务 ...")
|
||||
restart_cmd = (
|
||||
"cd %s && pkill -f 'soul-api' 2>/dev/null; sleep 2; "
|
||||
"nohup ./soul-api >> soul-api.log 2>&1 & sleep 1; "
|
||||
"pgrep -f soul-api >/dev/null && echo RESTART_OK || echo RESTART_FAIL"
|
||||
) % project_path
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=15)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if "RESTART_OK" in out:
|
||||
print(" [成功] soul-api 已重启")
|
||||
else:
|
||||
print(" [警告] 重启状态未知,请手动检查: cd %s && ./soul-api" % project_path)
|
||||
print("[4/4] 重启 soulApp 服务 ...")
|
||||
ok = False
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
if not ok and restart_method in ("auto", "ssh"):
|
||||
# SSH:用 setsid nohup 避免断开杀进程,多等几秒再检测
|
||||
restart_cmd = (
|
||||
"cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; "
|
||||
"setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 3; pgrep -f './soul-api' >/dev/null && echo RESTART_OK || echo RESTART_FAIL"
|
||||
) % project_path
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = (stderr.read().decode("utf-8", errors="replace") or "").strip()
|
||||
if err:
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
print("[4/4] 跳过重启 (--no-restart)")
|
||||
|
||||
@@ -187,12 +281,18 @@ def upload_and_extract(cfg, tarball_path, no_restart=False):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-api Go 项目一键部署到宝塔",
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
parser.add_argument("--no-env", action="store_true", help="不打包 .env(保留服务器现有 .env)")
|
||||
parser.add_argument("--no-restart", action="store_true", help="上传后不重启服务")
|
||||
parser.add_argument(
|
||||
"--restart-method",
|
||||
choices=("auto", "btapi", "ssh"),
|
||||
default="auto",
|
||||
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -200,7 +300,7 @@ def main():
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-api 一键部署到宝塔")
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
@@ -221,7 +321,7 @@ def main():
|
||||
if not tarball:
|
||||
return 1
|
||||
|
||||
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart):
|
||||
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method):
|
||||
return 1
|
||||
|
||||
try:
|
||||
|
||||
@@ -13,10 +13,16 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 // indirect
|
||||
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.9 // indirect
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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
|
||||
@@ -33,14 +39,23 @@ require (
|
||||
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/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // 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
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBjmgLnEwCLqs=
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
|
||||
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.9 h1:ItdVnpav2gmYdf3kM9wiXXoQwn+FVTYDZx0ZA/Ee48I=
|
||||
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.9/go.mod h1:VZQNCvcK/rldF3QaExiSl1gJEAkyc5/I8RLOd3WFZq4=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
|
||||
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
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=
|
||||
@@ -10,6 +21,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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=
|
||||
@@ -18,6 +31,7 @@ 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-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
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=
|
||||
@@ -58,10 +72,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
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/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
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=
|
||||
@@ -82,19 +102,37 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
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=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
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/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
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=
|
||||
@@ -104,6 +142,8 @@ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
|
||||
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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
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=
|
||||
|
||||
@@ -2,17 +2,62 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config 应用配置(从环境变量读取)
|
||||
// Config 应用配置(从环境变量读取,启动时加载 .env)
|
||||
type Config struct {
|
||||
Port string
|
||||
Mode string
|
||||
DBDSN string
|
||||
Port string
|
||||
Mode string
|
||||
DBDSN string
|
||||
TrustedProxies []string
|
||||
CORSOrigins []string
|
||||
CORSOrigins []string
|
||||
Version string // APP_VERSION,打包/部署前写在 .env,/health 返回
|
||||
|
||||
// 微信小程序配置
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatMchID string
|
||||
WechatMchKey string
|
||||
WechatNotifyURL string
|
||||
|
||||
// 微信转账配置(API v3)
|
||||
WechatAPIv3Key string
|
||||
WechatCertPath string
|
||||
WechatKeyPath string
|
||||
WechatSerialNo string
|
||||
WechatTransferURL string // 转账回调地址
|
||||
}
|
||||
|
||||
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
|
||||
var defaultCORSOrigins = []string{
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://soul.quwanzhi.com",
|
||||
"http://soul.quwanzhi.com",
|
||||
"https://soulapi.quwanzhi.com",
|
||||
"http://soulapi.quwanzhi.com",
|
||||
}
|
||||
|
||||
// parseCORSOrigins 从环境变量 CORS_ORIGINS 读取(逗号分隔),未设置则用默认值
|
||||
func parseCORSOrigins() []string {
|
||||
s := os.Getenv("CORS_ORIGINS")
|
||||
if s == "" {
|
||||
return defaultCORSOrigins
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
origins := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if o := strings.TrimSpace(p); o != "" {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return defaultCORSOrigins
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// Load 加载配置,开发环境可读 .env
|
||||
@@ -31,12 +76,71 @@ func Load() (*Config, error) {
|
||||
if dsn == "" {
|
||||
dsn = "user:pass@tcp(127.0.0.1:3306)/soul?charset=utf8mb4&parseTime=True"
|
||||
}
|
||||
version := os.Getenv("APP_VERSION")
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
// 微信配置
|
||||
wechatAppID := os.Getenv("WECHAT_APPID")
|
||||
if wechatAppID == "" {
|
||||
wechatAppID = "wxb8bbb2b10dec74aa" // 默认小程序AppID
|
||||
}
|
||||
wechatAppSecret := os.Getenv("WECHAT_APPSECRET")
|
||||
if wechatAppSecret == "" {
|
||||
wechatAppSecret = "3c1fb1f63e6e052222bbcead9d07fe0c" // 默认小程序AppSecret
|
||||
}
|
||||
wechatMchID := os.Getenv("WECHAT_MCH_ID")
|
||||
if wechatMchID == "" {
|
||||
wechatMchID = "1318592501" // 默认商户号
|
||||
}
|
||||
wechatMchKey := os.Getenv("WECHAT_MCH_KEY")
|
||||
if wechatMchKey == "" {
|
||||
wechatMchKey = "wx3e31b068be59ddc131b068be59ddc2" // 默认API密钥(v2)
|
||||
}
|
||||
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
|
||||
if wechatNotifyURL == "" {
|
||||
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
|
||||
}
|
||||
|
||||
// 转账配置
|
||||
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
|
||||
if wechatAPIv3Key == "" {
|
||||
wechatAPIv3Key = "wx3e31b068be59ddc131b068be59ddc2" // 默认 API v3 密钥
|
||||
}
|
||||
wechatCertPath := os.Getenv("WECHAT_CERT_PATH")
|
||||
if wechatCertPath == "" {
|
||||
wechatCertPath = "certs/apiclient_cert.pem" // 默认证书路径
|
||||
}
|
||||
wechatKeyPath := os.Getenv("WECHAT_KEY_PATH")
|
||||
if wechatKeyPath == "" {
|
||||
wechatKeyPath = "certs/apiclient_key.pem" // 默认私钥路径
|
||||
}
|
||||
wechatSerialNo := os.Getenv("WECHAT_SERIAL_NO")
|
||||
if wechatSerialNo == "" {
|
||||
wechatSerialNo = "4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5" // 默认证书序列号
|
||||
}
|
||||
wechatTransferURL := os.Getenv("WECHAT_TRANSFER_URL")
|
||||
if wechatTransferURL == "" {
|
||||
wechatTransferURL = "https://soul.quwanzhi.com/api/payment/wechat/transfer/notify" // 默认转账回调地址
|
||||
}
|
||||
|
||||
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"},
|
||||
Port: port,
|
||||
Mode: mode,
|
||||
DBDSN: dsn,
|
||||
TrustedProxies: []string{"127.0.0.1", "::1"},
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
Version: version,
|
||||
WechatAppID: wechatAppID,
|
||||
WechatAppSecret: wechatAppSecret,
|
||||
WechatMchID: wechatMchID,
|
||||
WechatMchKey: wechatMchKey,
|
||||
WechatNotifyURL: wechatNotifyURL,
|
||||
WechatAPIv3Key: wechatAPIv3Key,
|
||||
WechatCertPath: wechatCertPath,
|
||||
WechatKeyPath: wechatKeyPath,
|
||||
WechatSerialNo: wechatSerialNo,
|
||||
WechatTransferURL: wechatTransferURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -142,9 +143,44 @@ func BookLatestChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookSearch GET /api/book/search 同 /api/search,由 SearchGet 处理
|
||||
func escapeLikeBook(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
s = strings.ReplaceAll(s, "_", "\\_")
|
||||
return s
|
||||
}
|
||||
|
||||
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
|
||||
func BookSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""})
|
||||
return
|
||||
}
|
||||
pattern := "%" + escapeLikeBook(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(20).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
|
||||
return
|
||||
}
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
matchType := "content"
|
||||
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
|
||||
matchType = "title"
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
|
||||
"isFree": ch.IsFree, "matchType": matchType,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})
|
||||
}
|
||||
|
||||
// BookStats GET /api/book/stats
|
||||
|
||||
@@ -1,19 +1,200 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||
|
||||
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
|
||||
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
|
||||
|
||||
func ckbSign(params map[string]interface{}, apiKey string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if k == "sign" || k == "apiKey" || k == "portrait" {
|
||||
continue
|
||||
}
|
||||
v := params[k]
|
||||
if v == nil || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var concat string
|
||||
for _, k := range keys {
|
||||
switch v := params[k].(type) {
|
||||
case string:
|
||||
concat += v
|
||||
case float64:
|
||||
concat += strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case int:
|
||||
concat += strconv.Itoa(v)
|
||||
default:
|
||||
concat += ""
|
||||
}
|
||||
}
|
||||
h := md5.Sum([]byte(concat))
|
||||
first := hex.EncodeToString(h[:])
|
||||
h2 := md5.Sum([]byte(first + apiKey))
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
// CKBJoin POST /api/ckb/join
|
||||
func CKBJoin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Phone string `json:"phone"`
|
||||
Wechat string `json:"wechat"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"userId"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
if body.Phone == "" && body.Wechat == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
if body.Type != "team" && body.Type != "investor" && body.Type != "mentor" && body.Type != "partner" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
|
||||
return
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": "创业实验-" + ckbSourceMap[body.Type],
|
||||
"tags": ckbTagsMap[body.Type],
|
||||
"siteTags": "创业实验APP",
|
||||
"remark": body.Remark,
|
||||
}
|
||||
if body.Remark == "" {
|
||||
params["remark"] = "用户通过创业实验APP申请" + ckbSourceMap[body.Type]
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
params["wechatId"] = body.Wechat
|
||||
}
|
||||
if body.Name != "" {
|
||||
params["name"] = body.Name
|
||||
}
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": map[string]interface{}{
|
||||
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
|
||||
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
"remark": ckbSourceMap[body.Type] + "申请",
|
||||
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "成功加入" + ckbSourceMap[body.Type]
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已加入,我们会尽快联系您"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": result.Message})
|
||||
}
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
func CKBMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
Wechat string `json:"wechat"`
|
||||
UserID string `json:"userId"`
|
||||
Nickname string `json:"nickname"`
|
||||
MatchedUser interface{} `json:"matchedUser"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Phone == "" && body.Wechat == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
label := ckbSourceMap[body.MatchType]
|
||||
if label == "" {
|
||||
label = "创业合伙"
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": "创业实验-找伙伴匹配",
|
||||
"tags": "找伙伴," + label,
|
||||
"siteTags": "创业实验APP,匹配用户",
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
params["wechatId"] = body.Wechat
|
||||
}
|
||||
if body.Nickname != "" {
|
||||
params["name"] = body.Nickname
|
||||
}
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": map[string]interface{}{
|
||||
"action": "match", "matchType": body.MatchType, "matchLabel": label,
|
||||
"userId": body.UserID, "device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
"remark": "找伙伴匹配-" + label,
|
||||
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
}
|
||||
|
||||
// CKBSync GET/POST /api/ckb/sync
|
||||
|
||||
@@ -12,7 +12,89 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBConfigGet GET /api/db/config
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// 从 system_config 读取 free_chapters、mp_config、feature_config、chapter_config,合并后返回
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||
defaultMp := gin.H{"appId": "wxb8bbb2b10dec74aa", "apiDomain": "https://soul.quwanzhi.com", "buyerDiscount": 5, "referralBindDays": 30, "minWithdraw": 10}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"freeChapters": defaultFree,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
keys := []string{"chapter_config", "free_chapters", "feature_config", "mp_config"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "chapter_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
if v, ok := m["freeChapters"].([]interface{}); ok && len(v) > 0 {
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
out["freeChapters"] = arr
|
||||
}
|
||||
}
|
||||
if v, ok := m["prices"].(map[string]interface{}); ok {
|
||||
out["prices"] = v
|
||||
}
|
||||
if v, ok := m["features"].(map[string]interface{}); ok {
|
||||
out["features"] = v
|
||||
}
|
||||
out["configs"].(gin.H)["chapter_config"] = m
|
||||
}
|
||||
case "free_chapters":
|
||||
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
|
||||
ss := make([]string, 0, len(arr))
|
||||
for _, x := range arr {
|
||||
if s, ok := x.(string); ok {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
}
|
||||
if len(ss) > 0 {
|
||||
out["freeChapters"] = ss
|
||||
}
|
||||
out["configs"].(gin.H)["free_chapters"] = arr
|
||||
}
|
||||
case "feature_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
||||
cur := out["features"].(gin.H)
|
||||
for kk, vv := range m {
|
||||
cur[kk] = vv
|
||||
}
|
||||
out["configs"].(gin.H)["feature_config"] = m
|
||||
}
|
||||
case "mp_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["mpConfig"] = m
|
||||
out["configs"].(gin.H)["mp_config"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用)
|
||||
func DBConfigGet(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
db := database.DB()
|
||||
|
||||
@@ -1,14 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var defaultMatchTypes = []gin.H{
|
||||
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
||||
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "商业顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
}
|
||||
|
||||
// MatchConfigGet GET /api/match/config
|
||||
func MatchConfigGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
db := database.DB()
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"matchTypes": defaultMatchTypes,
|
||||
"freeMatchLimit": 3,
|
||||
"matchPrice": 1,
|
||||
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
||||
},
|
||||
"source": "default",
|
||||
})
|
||||
return
|
||||
}
|
||||
var config map[string]interface{}
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &config)
|
||||
matchTypes := defaultMatchTypes
|
||||
if v, ok := config["matchTypes"].([]interface{}); ok && len(v) > 0 {
|
||||
matchTypes = make([]gin.H, 0, len(v))
|
||||
for _, t := range v {
|
||||
if m, ok := t.(map[string]interface{}); ok {
|
||||
enabled := true
|
||||
if e, ok := m["enabled"].(bool); ok && !e {
|
||||
enabled = false
|
||||
}
|
||||
if enabled {
|
||||
matchTypes = append(matchTypes, gin.H(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matchTypes) == 0 {
|
||||
matchTypes = defaultMatchTypes
|
||||
}
|
||||
}
|
||||
freeMatchLimit := 3
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok {
|
||||
freeMatchLimit = int(v)
|
||||
}
|
||||
matchPrice := 1
|
||||
if v, ok := config["matchPrice"].(float64); ok {
|
||||
matchPrice = int(v)
|
||||
}
|
||||
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
|
||||
if s, ok := config["settings"].(map[string]interface{}); ok {
|
||||
for k, v := range s {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings,
|
||||
}, "source": "database"})
|
||||
}
|
||||
|
||||
// MatchConfigPost POST /api/match/config
|
||||
@@ -16,7 +78,64 @@ func MatchConfigPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// MatchUsers POST /api/match/users (Next 为 POST,拆解计划写 GET,两法都挂)
|
||||
// MatchUsers POST /api/match/users
|
||||
func MatchUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
MatchType string `json:"matchType"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
var users []model.User
|
||||
if err := database.DB().Where("id != ?", body.UserID).Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil})
|
||||
return
|
||||
}
|
||||
// 随机选一个
|
||||
idx := 0
|
||||
if len(users) > 1 {
|
||||
idx = int(users[0].CreatedAt.Unix() % int64(len(users)))
|
||||
}
|
||||
r := users[idx]
|
||||
nickname := "微信用户"
|
||||
if r.Nickname != nil {
|
||||
nickname = *r.Nickname
|
||||
}
|
||||
avatar := ""
|
||||
if r.Avatar != nil {
|
||||
avatar = *r.Avatar
|
||||
}
|
||||
wechat := ""
|
||||
if r.WechatID != nil {
|
||||
wechat = *r.WechatID
|
||||
}
|
||||
phone := ""
|
||||
if r.Phone != nil {
|
||||
phone = *r.Phone
|
||||
if len(phone) == 11 {
|
||||
phone = phone[:3] + "****" + phone[7:]
|
||||
}
|
||||
}
|
||||
intro := "来自Soul创业派对的伙伴"
|
||||
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
||||
tag := matchLabels[body.MatchType]
|
||||
if tag == "" {
|
||||
tag = "找伙伴"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
|
||||
"introduction": intro, "tags": []string{"创业者", tag},
|
||||
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
|
||||
"commonInterests": []gin.H{
|
||||
gin.H{"icon": "📚", "text": "都在读《创业派对》"},
|
||||
gin.H{"icon": "💼", "text": "对创业感兴趣"},
|
||||
gin.H{"icon": "🎯", "text": "相似的发展方向"},
|
||||
},
|
||||
},
|
||||
"totalUsers": len(users),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,32 +1,744 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniprogramLogin POST /api/miniprogram/login
|
||||
func MiniprogramLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信接口获取 openid 和 session_key
|
||||
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户是否存在
|
||||
var user model.User
|
||||
result := db.Where("open_id = ?", openID).First(&user)
|
||||
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 创建新用户
|
||||
userID := openID // 直接使用 openid 作为用户 ID
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
hasFullBook := false
|
||||
earnings := 0.0
|
||||
pendingEarnings := 0.0
|
||||
referralCount := 0
|
||||
purchasedSections := "[]"
|
||||
|
||||
user = model.User{
|
||||
ID: userID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
Avatar: &avatar,
|
||||
ReferralCode: &referralCode,
|
||||
HasFullBook: &hasFullBook,
|
||||
PurchasedSections: &purchasedSections,
|
||||
Earnings: &earnings,
|
||||
PendingEarnings: &pendingEarnings,
|
||||
ReferralCount: &referralCount,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新 session_key
|
||||
db.Model(&user).Update("session_key", sessionKey)
|
||||
}
|
||||
|
||||
// 从 orders 表查询真实购买记录
|
||||
var purchasedSections []string
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
|
||||
db.Raw(`
|
||||
SELECT DISTINCT product_id
|
||||
FROM orders
|
||||
WHERE user_id = ?
|
||||
AND status = 'paid'
|
||||
AND product_type = 'section'
|
||||
`, user.ID).Scan(&orderRows)
|
||||
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
if purchasedSections == nil {
|
||||
purchasedSections = []string{}
|
||||
}
|
||||
|
||||
// 构建返回的用户对象
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"openId": getStringValue(user.OpenID),
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
"hasFullBook": getBoolValue(user.HasFullBook),
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": getFloatValue(user.Earnings),
|
||||
"pendingEarnings": getFloatValue(user.PendingEarnings),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
"createdAt": user.CreatedAt,
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
"isNewUser": isNewUser,
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getBoolValue(ptr *bool) bool {
|
||||
if ptr == nil {
|
||||
return false
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getFloatValue(ptr *float64) float64 {
|
||||
if ptr == nil {
|
||||
return 0.0
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getIntValue(ptr *int) int {
|
||||
if ptr == nil {
|
||||
return 0
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
// MiniprogramPay GET/POST /api/miniprogram/pay
|
||||
func MiniprogramPay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
if c.Request.Method == "POST" {
|
||||
miniprogramPayPost(c)
|
||||
} else {
|
||||
miniprogramPayGet(c)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建小程序支付订单
|
||||
func miniprogramPayPost(c *gin.Context) {
|
||||
var req struct {
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 获取推广配置计算好友优惠
|
||||
finalAmount := req.Amount
|
||||
if req.ReferralCode != "" {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = req.Amount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
totalFee := int(finalAmount * 100) // 转为分
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
if req.ProductType == "fullbook" {
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
} else {
|
||||
description = fmt.Sprintf("章节购买-%s", req.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户端 IP
|
||||
clientIP := c.ClientIP()
|
||||
if clientIP == "" {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// 查询用户的有效推荐人
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有绑定,尝试从邀请码解析推荐人
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 插入订单到数据库
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
// 订单创建失败,但不中断支付流程
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
}
|
||||
|
||||
// 调用微信统一下单
|
||||
params := map[string]string{
|
||||
"body": description,
|
||||
"out_trade_no": orderSn,
|
||||
"total_fee": fmt.Sprintf("%d", totalFee),
|
||||
"spbill_create_ip": clientIP,
|
||||
"notify_url": "https://soul.quwanzhi.com/api/miniprogram/pay/notify",
|
||||
"trade_type": "JSAPI",
|
||||
"openid": req.OpenID,
|
||||
"attach": fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID),
|
||||
}
|
||||
|
||||
result, err := wechat.PayV2UnifiedOrder(params)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
prepayID := result["prepay_id"]
|
||||
if prepayID == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "微信支付返回数据异常"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成小程序支付参数
|
||||
payParams := wechat.GenerateJSAPIPayParams(prepayID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"orderSn": orderSn,
|
||||
"prepayId": prepayID,
|
||||
"payParams": payParams,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GET - 查询订单状态
|
||||
func miniprogramPayGet(c *gin.Context) {
|
||||
orderSn := c.Query("orderSn")
|
||||
if orderSn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := wechat.PayV2OrderQuery(orderSn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": "unknown",
|
||||
"orderSn": orderSn,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 映射微信支付状态
|
||||
tradeState := result["trade_state"]
|
||||
status := "paying"
|
||||
|
||||
switch tradeState {
|
||||
case "SUCCESS":
|
||||
status = "paid"
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
case "REFUND":
|
||||
status = "refunded"
|
||||
}
|
||||
|
||||
totalFee := 0
|
||||
if result["total_fee"] != "" {
|
||||
fmt.Sscanf(result["total_fee"], "%d", &totalFee)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": status,
|
||||
"orderSn": orderSn,
|
||||
"transactionId": result["transaction_id"],
|
||||
"totalFee": totalFee,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramPayNotify POST /api/miniprogram/pay/notify
|
||||
func MiniprogramPayNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
// 读取 XML body
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, failResponse())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 XML
|
||||
data := wechat.XMLToMap(string(body))
|
||||
|
||||
// 验证签名
|
||||
if !wechat.VerifyPayNotify(data) {
|
||||
fmt.Println("[PayNotify] 签名验证失败")
|
||||
c.String(http.StatusOK, failResponse())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查支付结果
|
||||
if data["return_code"] != "SUCCESS" || data["result_code"] != "SUCCESS" {
|
||||
fmt.Printf("[PayNotify] 支付未成功: %s\n", data["err_code"])
|
||||
c.String(http.StatusOK, successResponse())
|
||||
return
|
||||
}
|
||||
|
||||
orderSn := data["out_trade_no"]
|
||||
transactionID := data["transaction_id"]
|
||||
totalFee := 0
|
||||
fmt.Sscanf(data["total_fee"], "%d", &totalFee)
|
||||
totalAmount := float64(totalFee) / 100
|
||||
openID := data["openid"]
|
||||
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
// 解析附加数据
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if data["attach"] != "" {
|
||||
json.Unmarshal([]byte(data["attach"]), &attach)
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 用 openID 解析真实买家身份
|
||||
buyerUserID := attach.UserID
|
||||
if openID != "" {
|
||||
var user model.User
|
||||
if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil {
|
||||
if attach.UserID != "" && user.ID != attach.UserID {
|
||||
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
|
||||
}
|
||||
buyerUserID = user.ID
|
||||
}
|
||||
}
|
||||
if buyerUserID == "" && attach.UserID != "" {
|
||||
buyerUserID = attach.UserID
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
var order model.Order
|
||||
result := db.Where("order_sn = ?", orderSn).First(&order)
|
||||
|
||||
if result.Error != nil {
|
||||
// 订单不存在,补记订单
|
||||
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
|
||||
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
productType := attach.ProductType
|
||||
if productType == "" {
|
||||
productType = "unknown"
|
||||
}
|
||||
desc := "支付回调补记订单"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
|
||||
order = model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: buyerUserID,
|
||||
OpenID: openID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: totalAmount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
TransactionID: &transactionID,
|
||||
PayTime: &now,
|
||||
}
|
||||
|
||||
db.Create(&order)
|
||||
} else if *order.Status != "paid" {
|
||||
// 更新订单状态
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
// 更新用户购买记录
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
// 全书购买
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
// 检查是否已有该章节的其他已支付订单
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
|
||||
if count == 0 {
|
||||
// 首次购买该章节,这里不需要更新 purchased_sections,因为查询时会从 orders 表读取
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理相同产品的无效订单
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
|
||||
result := db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
fmt.Printf("[PayNotify] 已清理无效订单: %d 个\n", result.RowsAffected)
|
||||
}
|
||||
|
||||
// 处理分销佣金
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn)
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, successResponse())
|
||||
}
|
||||
|
||||
// 处理分销佣金
|
||||
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string) {
|
||||
// 获取分成配置,默认 90%
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找有效推广绑定
|
||||
type Binding struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
ExpiryDate time.Time `gorm:"column:expiry_date"`
|
||||
PurchaseCount int `gorm:"column:purchase_count"`
|
||||
TotalCommission float64 `gorm:"column:total_commission"`
|
||||
}
|
||||
|
||||
var binding Binding
|
||||
err := db.Raw(`
|
||||
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active'
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, buyerUserID).Scan(&binding).Error
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().After(binding.ExpiryDate) {
|
||||
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算佣金
|
||||
commission := amount * distributorShare
|
||||
newPurchaseCount := binding.PurchaseCount + 1
|
||||
newTotalCommission := binding.TotalCommission + commission
|
||||
|
||||
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
|
||||
binding.ReferrerID, amount, commission, distributorShare*100)
|
||||
|
||||
// 更新推广者的待结算收益
|
||||
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
|
||||
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
|
||||
|
||||
// 更新绑定记录
|
||||
db.Exec(`
|
||||
UPDATE referral_bindings
|
||||
SET last_purchase_date = NOW(),
|
||||
purchase_count = purchase_count + 1,
|
||||
total_commission = total_commission + ?
|
||||
WHERE id = ?
|
||||
`, commission, binding.ID)
|
||||
|
||||
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
|
||||
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
|
||||
}
|
||||
|
||||
// 微信支付回调响应
|
||||
func successResponse() string {
|
||||
return `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
|
||||
}
|
||||
|
||||
func failResponse() string {
|
||||
return `<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>`
|
||||
}
|
||||
|
||||
// MiniprogramPhone POST /api/miniprogram/phone
|
||||
func MiniprogramPhone(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少code参数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取手机号
|
||||
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取手机号失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果提供了 userId,更新到数据库
|
||||
if req.UserID != "" {
|
||||
db := database.DB()
|
||||
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
|
||||
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"phoneNumber": phoneNumber,
|
||||
"countryCode": countryCode,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramQrcode POST /api/miniprogram/qrcode (Next 为 POST)
|
||||
// MiniprogramQrcode POST /api/miniprogram/qrcode
|
||||
func MiniprogramQrcode(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var req struct {
|
||||
Scene string `json:"scene"`
|
||||
Page string `json:"page"`
|
||||
Width int `json:"width"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 scene 参数
|
||||
scene := req.Scene
|
||||
if scene == "" {
|
||||
var parts []string
|
||||
if req.UserID != "" {
|
||||
userId := req.UserID
|
||||
if len(userId) > 15 {
|
||||
userId = userId[:15]
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("ref=%s", userId))
|
||||
}
|
||||
if req.ChapterID != "" {
|
||||
parts = append(parts, fmt.Sprintf("ch=%s", req.ChapterID))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
scene = "soul"
|
||||
} else {
|
||||
scene = strings.Join(parts, "&")
|
||||
}
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
if page == "" {
|
||||
page = "pages/index/index"
|
||||
}
|
||||
|
||||
width := req.Width
|
||||
if width == 0 {
|
||||
width = 280
|
||||
}
|
||||
|
||||
fmt.Printf("[MiniprogramQrcode] 生成小程序码, scene=%s\n", scene)
|
||||
|
||||
// 生成小程序码
|
||||
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("生成小程序码失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 base64
|
||||
base64Image := fmt.Sprintf("data:image/png;base64,%s", base64Encode(imageData))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"image": base64Image,
|
||||
"scene": scene,
|
||||
})
|
||||
}
|
||||
|
||||
// base64 编码
|
||||
func base64Encode(data []byte) string {
|
||||
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
var result strings.Builder
|
||||
|
||||
for i := 0; i < len(data); i += 3 {
|
||||
b1, b2, b3 := data[i], byte(0), byte(0)
|
||||
if i+1 < len(data) {
|
||||
b2 = data[i+1]
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
b3 = data[i+2]
|
||||
}
|
||||
|
||||
result.WriteByte(base64Table[b1>>2])
|
||||
result.WriteByte(base64Table[((b1&0x03)<<4)|(b2>>4)])
|
||||
|
||||
if i+1 < len(data) {
|
||||
result.WriteByte(base64Table[((b2&0x0F)<<2)|(b3>>6)])
|
||||
} else {
|
||||
result.WriteByte('=')
|
||||
}
|
||||
|
||||
if i+2 < len(data) {
|
||||
result.WriteByte(base64Table[b3&0x3F])
|
||||
} else {
|
||||
result.WriteByte('=')
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,5 +49,37 @@ func PaymentWechatNotify(c *gin.Context) {
|
||||
|
||||
// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
|
||||
func PaymentWechatTransferNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
// 微信转账回调处理
|
||||
// 注意:实际生产环境需要验证签名,这里简化处理
|
||||
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Summary string `json:"summary"`
|
||||
Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
} `json:"resource"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fmt.Printf("[TransferNotify] 解析请求失败: %v\n", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "请求格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[TransferNotify] 收到转账回调: event_type=%s\n", req.EventType)
|
||||
|
||||
// TODO: 使用 APIv3 密钥解密 resource.ciphertext
|
||||
// 解密后可以获取转账详情(outBatchNo、outDetailNo、detailStatus等)
|
||||
|
||||
// 暂时记录日志,实际处理需要解密后进行
|
||||
fmt.Printf("[TransferNotify] 转账回调数据: %+v\n", req)
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "OK"})
|
||||
}
|
||||
|
||||
@@ -1,22 +1,468 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReferralBind POST /api/referral/bind
|
||||
const defaultBindingDays = 30
|
||||
|
||||
// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换)
|
||||
func ReferralBind(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode" binding:"required"`
|
||||
OpenID string `json:"openId"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
effectiveUserID := req.UserID
|
||||
if effectiveUserID == "" && req.OpenID != "" {
|
||||
effectiveUserID = "user_" + req.OpenID[len(req.OpenID)-8:]
|
||||
}
|
||||
if effectiveUserID == "" || req.ReferralCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
bindingDays := defaultBindingDays
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["bindingDays"] != nil {
|
||||
if v, ok := config["bindingDays"].(float64); ok {
|
||||
bindingDays = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var referrer model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
|
||||
return
|
||||
}
|
||||
if referrer.ID == effectiveUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
|
||||
if req.OpenID != "" {
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
expiryDate := time.Now().AddDate(0, 0, bindingDays)
|
||||
var existing model.ReferralBinding
|
||||
err := db.Where("referee_id = ? AND status = ?", user.ID, "active").Order("binding_date DESC").First(&existing).Error
|
||||
action := "new"
|
||||
var oldReferrerID interface{}
|
||||
|
||||
if err == nil {
|
||||
if existing.ReferrerID == referrer.ID {
|
||||
action = "renew"
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
"expiry_date": expiryDate,
|
||||
"binding_date": time.Now(),
|
||||
})
|
||||
} else {
|
||||
action = "switch"
|
||||
oldReferrerID = existing.ReferrerID
|
||||
db.Model(&existing).Update("status", "cancelled")
|
||||
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
|
||||
db.Create(&model.ReferralBinding{
|
||||
ID: bindID,
|
||||
ReferrerID: referrer.ID,
|
||||
RefereeID: user.ID,
|
||||
ReferralCode: req.ReferralCode,
|
||||
Status: refString("active"),
|
||||
ExpiryDate: expiryDate,
|
||||
BindingDate: time.Now(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
|
||||
db.Create(&model.ReferralBinding{
|
||||
ID: bindID,
|
||||
ReferrerID: referrer.ID,
|
||||
RefereeID: user.ID,
|
||||
ReferralCode: req.ReferralCode,
|
||||
Status: refString("active"),
|
||||
ExpiryDate: expiryDate,
|
||||
BindingDate: time.Now(),
|
||||
})
|
||||
db.Model(&model.User{}).Where("id = ?", referrer.ID).UpdateColumn("referral_count", gorm.Expr("COALESCE(referral_count, 0) + 1"))
|
||||
}
|
||||
|
||||
msg := "绑定成功"
|
||||
if action == "renew" {
|
||||
msg = "绑定已续期"
|
||||
} else if action == "switch" {
|
||||
msg = "推荐人已切换"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
"data": gin.H{
|
||||
"action": action,
|
||||
"referrer": gin.H{"id": referrer.ID, "nickname": getStringValue(referrer.Nickname)},
|
||||
"expiryDate": expiryDate,
|
||||
"bindingDays": bindingDays,
|
||||
"oldReferrerId": oldReferrerID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReferralData GET /api/referral/data
|
||||
func refString(s string) *string { return &s }
|
||||
func randomStr(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ReferralData GET /api/referral/data 获取分销数据统计
|
||||
func ReferralData(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 获取分销配置
|
||||
distributorShare := 0.9
|
||||
minWithdrawAmount := 10.0
|
||||
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
if minAmount, ok := config["minWithdrawAmount"].(float64); ok {
|
||||
minWithdrawAmount = minAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 查询用户基本信息
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 绑定统计
|
||||
var totalBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("referrer_id = ?", userId).Count(&totalBindings)
|
||||
|
||||
var activeBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND status = 'active' AND expiry_date > ?",
|
||||
userId, time.Now(),
|
||||
).Count(&activeBindings)
|
||||
|
||||
var convertedBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND status = 'active' AND purchase_count > 0",
|
||||
userId,
|
||||
).Count(&convertedBindings)
|
||||
|
||||
var expiredBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= ?))",
|
||||
userId, time.Now(),
|
||||
).Count(&expiredBindings)
|
||||
|
||||
// 3. 付款统计
|
||||
var paidOrders []struct {
|
||||
Amount float64
|
||||
UserID string
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("amount, user_id").
|
||||
Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Find(&paidOrders)
|
||||
|
||||
totalAmount := 0.0
|
||||
uniqueUsers := make(map[string]bool)
|
||||
for _, order := range paidOrders {
|
||||
totalAmount += order.Amount
|
||||
uniqueUsers[order.UserID] = true
|
||||
}
|
||||
uniquePaidCount := len(uniqueUsers)
|
||||
|
||||
// 4. 访问统计
|
||||
totalVisits := int(totalBindings)
|
||||
var visitCount int64
|
||||
if err := db.Model(&model.ReferralVisit{}).
|
||||
Select("COUNT(DISTINCT visitor_id) as count").
|
||||
Where("referrer_id = ?", userId).
|
||||
Count(&visitCount).Error; err == nil {
|
||||
totalVisits = int(visitCount)
|
||||
}
|
||||
|
||||
// 5. 提现统计
|
||||
var pendingWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = 'pending'", userId).
|
||||
Scan(&pendingWithdraw)
|
||||
|
||||
var successWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = 'success'", userId).
|
||||
Scan(&successWithdraw)
|
||||
|
||||
pendingWithdrawAmount := pendingWithdraw.Total
|
||||
withdrawnFromTable := successWithdraw.Total
|
||||
|
||||
// 6. 获取活跃绑定用户列表
|
||||
var activeBindingsList []model.ReferralBinding
|
||||
db.Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
|
||||
Order("binding_date DESC").
|
||||
Limit(20).
|
||||
Find(&activeBindingsList)
|
||||
|
||||
activeUsers := []gin.H{}
|
||||
for _, b := range activeBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
daysRemaining := int(time.Until(b.ExpiryDate).Hours() / 24)
|
||||
if daysRemaining < 0 {
|
||||
daysRemaining = 0
|
||||
}
|
||||
|
||||
activeUsers = append(activeUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"daysRemaining": daysRemaining,
|
||||
"hasFullBook": getBoolValue(referee.HasFullBook),
|
||||
"bindingDate": b.BindingDate,
|
||||
"status": "active",
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 获取已转化用户列表
|
||||
var convertedBindingsList []model.ReferralBinding
|
||||
db.Where("referrer_id = ? AND status = 'active' AND purchase_count > 0", userId).
|
||||
Order("last_purchase_date DESC").
|
||||
Limit(20).
|
||||
Find(&convertedBindingsList)
|
||||
|
||||
convertedUsers := []gin.H{}
|
||||
for _, b := range convertedBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
commission := 0.0
|
||||
if b.TotalCommission != nil {
|
||||
commission = *b.TotalCommission
|
||||
}
|
||||
orderAmount := commission / distributorShare
|
||||
|
||||
convertedUsers = append(convertedUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"commission": commission,
|
||||
"orderAmount": orderAmount,
|
||||
"purchaseCount": getIntValue(b.PurchaseCount),
|
||||
"conversionDate": b.LastPurchaseDate,
|
||||
"status": "converted",
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 获取已过期用户列表
|
||||
var expiredBindingsList []model.ReferralBinding
|
||||
db.Where(
|
||||
"referrer_id = ? AND (status = 'expired' OR (status = 'active' AND expiry_date <= ?))",
|
||||
userId, time.Now(),
|
||||
).Order("expiry_date DESC").Limit(20).Find(&expiredBindingsList)
|
||||
|
||||
expiredUsers := []gin.H{}
|
||||
for _, b := range expiredBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
expiredUsers = append(expiredUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"bindingDate": b.BindingDate,
|
||||
"expiryDate": b.ExpiryDate,
|
||||
"status": "expired",
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 获取收益明细
|
||||
var earningsDetailsList []model.Order
|
||||
db.Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Order("pay_time DESC").
|
||||
Limit(20).
|
||||
Find(&earningsDetailsList)
|
||||
|
||||
earningsDetails := []gin.H{}
|
||||
for _, e := range earningsDetailsList {
|
||||
var buyer model.User
|
||||
db.Where("id = ?", e.UserID).First(&buyer)
|
||||
|
||||
commission := e.Amount * distributorShare
|
||||
earningsDetails = append(earningsDetails, gin.H{
|
||||
"id": e.ID,
|
||||
"orderSn": e.OrderSN,
|
||||
"amount": e.Amount,
|
||||
"commission": commission,
|
||||
"productType": e.ProductType,
|
||||
"productId": getStringValue(e.ProductID),
|
||||
"description": getStringValue(e.Description),
|
||||
"buyerNickname": getStringValue(buyer.Nickname),
|
||||
"buyerAvatar": getStringValue(buyer.Avatar),
|
||||
"payTime": e.PayTime,
|
||||
})
|
||||
}
|
||||
|
||||
// 计算收益
|
||||
totalCommission := totalAmount * distributorShare
|
||||
estimatedEarnings := totalAmount * distributorShare
|
||||
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
|
||||
// 计算即将过期用户数(7天内)
|
||||
sevenDaysLater := time.Now().Add(7 * 24 * time.Hour)
|
||||
expiringCount := 0
|
||||
for _, b := range activeBindingsList {
|
||||
if b.ExpiryDate.After(time.Now()) && b.ExpiryDate.Before(sevenDaysLater) {
|
||||
expiringCount++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
// 核心可见数据
|
||||
"bindingCount": activeBindings,
|
||||
"visitCount": totalVisits,
|
||||
"paidCount": uniquePaidCount,
|
||||
"expiredCount": expiredBindings,
|
||||
|
||||
// 收益数据
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"pendingWithdrawAmount": round(pendingWithdrawAmount, 2),
|
||||
"withdrawnEarnings": withdrawnFromTable,
|
||||
"earnings": getFloatValue(user.Earnings),
|
||||
"pendingEarnings": getFloatValue(user.PendingEarnings),
|
||||
"estimatedEarnings": round(estimatedEarnings, 2),
|
||||
"shareRate": int(distributorShare * 100),
|
||||
"minWithdrawAmount": minWithdrawAmount,
|
||||
|
||||
// 推荐码
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
|
||||
// 详细统计
|
||||
"stats": gin.H{
|
||||
"totalBindings": totalBindings,
|
||||
"activeBindings": activeBindings,
|
||||
"convertedBindings": convertedBindings,
|
||||
"expiredBindings": expiredBindings,
|
||||
"expiringCount": expiringCount,
|
||||
"totalPaymentAmount": totalAmount,
|
||||
},
|
||||
|
||||
// 用户列表
|
||||
"activeUsers": activeUsers,
|
||||
"convertedUsers": convertedUsers,
|
||||
"expiredUsers": expiredUsers,
|
||||
|
||||
// 收益明细
|
||||
"earningsDetails": earningsDetails,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReferralVisit POST /api/referral/visit
|
||||
func ReferralVisit(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
// round 四舍五入保留小数
|
||||
func round(val float64, precision int) float64 {
|
||||
ratio := math.Pow(10, float64(precision))
|
||||
return math.Round(val*ratio) / ratio
|
||||
}
|
||||
|
||||
// ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录)
|
||||
func ReferralVisit(c *gin.Context) {
|
||||
var req struct {
|
||||
ReferralCode string `json:"referralCode" binding:"required"`
|
||||
VisitorOpenID string `json:"visitorOpenId"`
|
||||
VisitorID string `json:"visitorId"`
|
||||
Source string `json:"source"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var referrer model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
|
||||
return
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "miniprogram"
|
||||
}
|
||||
visitorID := req.VisitorID
|
||||
if visitorID == "" {
|
||||
visitorID = ""
|
||||
}
|
||||
vOpenID := req.VisitorOpenID
|
||||
vPage := req.Page
|
||||
err := db.Create(&model.ReferralVisit{
|
||||
ReferrerID: referrer.ID,
|
||||
VisitorID: strPtrOrNil(visitorID),
|
||||
VisitorOpenID: strPtrOrNil(vOpenID),
|
||||
Source: strPtrOrNil(source),
|
||||
Page: strPtrOrNil(vPage),
|
||||
}).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已处理"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "访问已记录"})
|
||||
}
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -1,17 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadPost POST /api/upload
|
||||
const uploadDir = "uploads"
|
||||
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
|
||||
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
|
||||
|
||||
// UploadPost POST /api/upload 上传图片(表单 file)
|
||||
func UploadPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": ""})
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxUploadBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "文件大小不能超过5MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedTypes[ct] && !strings.HasPrefix(ct, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持图片格式"})
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "avatars"
|
||||
}
|
||||
dir := filepath.Join(uploadDir, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
}
|
||||
|
||||
func randomStrUpload(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// UploadDelete DELETE /api/upload
|
||||
func UploadDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
|
||||
return
|
||||
}
|
||||
fullPath := strings.TrimPrefix(path, "/")
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -12,49 +12,364 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserAddressesGet GET /api/user/addresses
|
||||
// UserAddressesGet GET /api/user/addresses?userId=
|
||||
func UserAddressesGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
var list []model.UserAddress
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("is_default DESC, updated_at DESC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": []interface{}{}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// UserAddressesPost POST /api/user/addresses
|
||||
func UserAddressesPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Detail string `json:"detail" binding:"required"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项:userId, name, phone, detail"})
|
||||
return
|
||||
}
|
||||
id := fmt.Sprintf("addr_%d", time.Now().UnixNano()%100000000000)
|
||||
db := database.DB()
|
||||
if body.IsDefault {
|
||||
db.Model(&model.UserAddress{}).Where("user_id = ?", body.UserID).Update("is_default", false)
|
||||
}
|
||||
addr := model.UserAddress{
|
||||
ID: id, UserID: body.UserID, Name: body.Name, Phone: body.Phone,
|
||||
Province: body.Province, City: body.City, District: body.District, Detail: body.Detail,
|
||||
IsDefault: body.IsDefault,
|
||||
}
|
||||
if err := db.Create(&addr).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "添加地址失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "id": id, "message": "添加成功"})
|
||||
}
|
||||
|
||||
// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id
|
||||
func UserAddressesByID(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少地址 id"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case "GET":
|
||||
var r model.UserAddress
|
||||
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
|
||||
return
|
||||
}
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
}})
|
||||
case "PUT":
|
||||
var r model.UserAddress
|
||||
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Phone *string `json:"phone"`
|
||||
Province *string `json:"province"`
|
||||
City *string `json:"city"`
|
||||
District *string `json:"district"`
|
||||
Detail *string `json:"detail"`
|
||||
IsDefault *bool `json:"isDefault"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
updates := make(map[string]interface{})
|
||||
if body.Name != nil { updates["name"] = *body.Name }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.Province != nil { updates["province"] = *body.Province }
|
||||
if body.City != nil { updates["city"] = *body.City }
|
||||
if body.District != nil { updates["district"] = *body.District }
|
||||
if body.Detail != nil { updates["detail"] = *body.Detail }
|
||||
if body.IsDefault != nil {
|
||||
updates["is_default"] = *body.IsDefault
|
||||
if *body.IsDefault {
|
||||
db.Model(&model.UserAddress{}).Where("user_id = ?", r.UserID).Update("is_default", false)
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&r).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&r)
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
}, "message": "更新成功"})
|
||||
case "DELETE":
|
||||
if err := db.Where("id = ?", id).Delete(&model.UserAddress{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
}
|
||||
}
|
||||
|
||||
// UserCheckPurchased GET /api/user/check-purchased
|
||||
// UserCheckPurchased GET /api/user/check-purchased?userId=&type=section|fullbook&productId=
|
||||
func UserCheckPurchased(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
userId := c.Query("userId")
|
||||
type_ := c.Query("type")
|
||||
productId := c.Query("productId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if hasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
if type_ == "fullbook" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
if type_ == "section" && productId != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": false, "reason": nil}})
|
||||
}
|
||||
|
||||
// UserProfileGet GET /api/user/profile
|
||||
// UserProfileGet GET /api/user/profile?userId= 或 openId=
|
||||
func UserProfileGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
userId := c.Query("userId")
|
||||
openId := c.Query("openId")
|
||||
if userId == "" && openId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if userId != "" {
|
||||
db = db.Where("id = ?", userId)
|
||||
} else {
|
||||
db = db.Where("open_id = ?", openId)
|
||||
}
|
||||
if err := db.First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
|
||||
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"id": user.ID, "openId": user.OpenID, "nickname": user.Nickname, "avatar": user.Avatar,
|
||||
"phone": user.Phone, "wechatId": user.WechatID, "referralCode": user.ReferralCode,
|
||||
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
|
||||
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
|
||||
"createdAt": user.CreatedAt,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserProfilePost POST /api/user/profile
|
||||
// UserProfilePost POST /api/user/profile 更新用户资料
|
||||
func UserProfilePost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
OpenID string `json:"openId"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
identifier := body.UserID
|
||||
byID := true
|
||||
if identifier == "" {
|
||||
identifier = body.OpenID
|
||||
byID = false
|
||||
}
|
||||
if identifier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if byID {
|
||||
db = db.Where("id = ?", identifier)
|
||||
} else {
|
||||
db = db.Where("open_id = ?", identifier)
|
||||
}
|
||||
if err := db.First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
|
||||
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.WechatID != nil { updates["wechat_id"] = *body.WechatID }
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&user).Updates(updates)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{
|
||||
"id": user.ID, "nickname": body.Nickname, "avatar": body.Avatar, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserPurchaseStatus GET /api/user/purchase-status
|
||||
// UserPurchaseStatus GET /api/user/purchase-status?userId=
|
||||
func UserPurchaseStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
var orderRows []struct{ ProductID string }
|
||||
db.Raw("SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = ? AND product_type = ?", userId, "paid", "section").Scan(&orderRows)
|
||||
purchasedSections := make([]string, 0, len(orderRows))
|
||||
for _, r := range orderRows {
|
||||
if r.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, r.ProductID)
|
||||
}
|
||||
}
|
||||
earnings := 0.0
|
||||
if user.Earnings != nil {
|
||||
earnings = *user.Earnings
|
||||
}
|
||||
pendingEarnings := 0.0
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
"earnings": earnings,
|
||||
"pendingEarnings": pendingEarnings,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserReadingProgressGet GET /api/user/reading-progress
|
||||
// UserReadingProgressGet GET /api/user/reading-progress?userId=
|
||||
func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
var list []model.ReadingProgress
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("last_open_at DESC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"section_id": r.SectionID, "progress": r.Progress, "duration": r.Duration, "status": r.Status,
|
||||
"completed_at": r.CompletedAt, "first_open_at": r.FirstOpenAt, "last_open_at": r.LastOpenAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// UserReadingProgressPost POST /api/user/reading-progress
|
||||
func UserReadingProgressPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
var existing model.ReadingProgress
|
||||
err := db.Where("user_id = ? AND section_id = ?", body.UserID, body.SectionID).First(&existing).Error
|
||||
if err == nil {
|
||||
newProgress := existing.Progress
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
} else if existing.CompletedAt != nil {
|
||||
completedAt = existing.CompletedAt
|
||||
}
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
"progress": newProgress, "duration": newDuration, "status": newStatus,
|
||||
"completed_at": completedAt, "last_open_at": now, "updated_at": now,
|
||||
})
|
||||
} else {
|
||||
status := body.Status
|
||||
if status == "" {
|
||||
status = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"})
|
||||
}
|
||||
|
||||
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM)
|
||||
@@ -151,7 +466,32 @@ func UserTrackPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
|
||||
}
|
||||
|
||||
// UserUpdate POST /api/user/update
|
||||
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
|
||||
func UserUpdate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
Wechat *string `json:"wechat"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
|
||||
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.Wechat != nil { updates["wechat_id"] = *body.Wechat }
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
if err := database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -10,3 +17,144 @@ import (
|
||||
func WechatLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// WechatPhoneLoginReq 手机号登录请求:code 为 wx.login() 的 code,phoneCode 为 getPhoneNumber 返回的 code
|
||||
type WechatPhoneLoginReq struct {
|
||||
Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId
|
||||
PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号
|
||||
}
|
||||
|
||||
// WechatPhoneLogin POST /api/wechat/phone-login
|
||||
// 请求体:code(必填)+ phoneCode(必填)。先 code2session 得到 openId,再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。
|
||||
func WechatPhoneLogin(c *gin.Context) {
|
||||
var req WechatPhoneLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.PhoneCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"})
|
||||
return
|
||||
}
|
||||
|
||||
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
|
||||
return
|
||||
}
|
||||
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
result := db.Where("open_id = ?", openID).First(&user)
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
hasFullBook := false
|
||||
earnings := 0.0
|
||||
pendingEarnings := 0.0
|
||||
referralCount := 0
|
||||
purchasedSections := "[]"
|
||||
phone := phoneNumber
|
||||
if countryCode != "" && countryCode != "86" {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
user = model.User{
|
||||
ID: openID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
Avatar: &avatar,
|
||||
Phone: &phone,
|
||||
ReferralCode: &referralCode,
|
||||
HasFullBook: &hasFullBook,
|
||||
PurchasedSections: &purchasedSections,
|
||||
Earnings: &earnings,
|
||||
PendingEarnings: &pendingEarnings,
|
||||
ReferralCount: &referralCount,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
phone := phoneNumber
|
||||
if countryCode != "" && countryCode != "86" {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone})
|
||||
user.Phone = &phone
|
||||
}
|
||||
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
db.Raw(`
|
||||
SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'
|
||||
`, user.ID).Scan(&orderRows)
|
||||
purchasedSections := []string{}
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"openId": strVal(user.OpenID),
|
||||
"nickname": strVal(user.Nickname),
|
||||
"avatar": strVal(user.Avatar),
|
||||
"phone": strVal(user.Phone),
|
||||
"wechatId": strVal(user.WechatID),
|
||||
"referralCode": strVal(user.ReferralCode),
|
||||
"hasFullBook": boolVal(user.HasFullBook),
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": floatVal(user.Earnings),
|
||||
"pendingEarnings": floatVal(user.PendingEarnings),
|
||||
"referralCount": intVal(user.ReferralCount),
|
||||
"createdAt": user.CreatedAt,
|
||||
}
|
||||
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
"isNewUser": isNewUser,
|
||||
})
|
||||
}
|
||||
|
||||
func strVal(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func boolVal(p *bool) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func floatVal(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func intVal(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
@@ -1,17 +1,154 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WithdrawPost POST /api/withdraw 创建提现申请(占位:仅返回成功,实际需对接微信打款)
|
||||
// WithdrawPost POST /api/withdraw 创建提现申请并发起微信转账
|
||||
func WithdrawPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
UserName string `json:"userName"` // 可选,实名校验用
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 金额验证
|
||||
if req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现金额必须大于0"})
|
||||
return
|
||||
}
|
||||
if req.Amount < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "最低提现金额为1元"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户信息,获取 openid 和待提现金额
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", req.UserID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.OpenID == nil || *user.OpenID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查待结算收益是否足够
|
||||
pendingEarnings := 0.0
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
|
||||
if pendingEarnings < req.Amount {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("可提现金额不足(当前:%.2f元)", pendingEarnings),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成转账单号
|
||||
outBatchNo := wechat.GenerateTransferBatchNo()
|
||||
outDetailNo := wechat.GenerateTransferDetailNo()
|
||||
|
||||
// 创建提现记录
|
||||
status := "pending"
|
||||
remark := req.Remark
|
||||
if remark == "" {
|
||||
remark = "提现"
|
||||
}
|
||||
|
||||
withdrawal := model.Withdrawal{
|
||||
ID: outDetailNo,
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: &status,
|
||||
BatchNo: &outBatchNo,
|
||||
DetailNo: &outDetailNo,
|
||||
Remark: &remark,
|
||||
}
|
||||
|
||||
if err := db.Create(&withdrawal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建提现记录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发起微信转账
|
||||
transferAmount := int(req.Amount * 100) // 转为分
|
||||
transferParams := wechat.TransferParams{
|
||||
OutBatchNo: outBatchNo,
|
||||
OutDetailNo: outDetailNo,
|
||||
OpenID: *user.OpenID,
|
||||
Amount: transferAmount,
|
||||
UserName: req.UserName,
|
||||
Remark: remark,
|
||||
BatchName: "用户提现",
|
||||
BatchRemark: fmt.Sprintf("用户 %s 提现 %.2f 元", req.UserID, req.Amount),
|
||||
}
|
||||
|
||||
result, err := wechat.InitiateTransfer(transferParams)
|
||||
if err != nil {
|
||||
// 转账失败,更新提现状态为失败
|
||||
failedStatus := "failed"
|
||||
failReason := fmt.Sprintf("发起转账失败: %v", err)
|
||||
db.Model(&withdrawal).Updates(map[string]interface{}{
|
||||
"status": failedStatus,
|
||||
"fail_reason": failReason,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "发起转账失败,请稍后重试",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新提现记录状态
|
||||
processingStatus := "processing"
|
||||
batchID := result.BatchID
|
||||
db.Model(&withdrawal).Updates(map[string]interface{}{
|
||||
"status": processingStatus,
|
||||
"batch_id": batchID,
|
||||
})
|
||||
|
||||
// 扣减用户的待结算收益,增加已提现金额
|
||||
db.Model(&user).Updates(map[string]interface{}{
|
||||
"pending_earnings": db.Raw("pending_earnings - ?", req.Amount),
|
||||
"withdrawn_earnings": db.Raw("COALESCE(withdrawn_earnings, 0) + ?", req.Amount),
|
||||
})
|
||||
|
||||
fmt.Printf("[Withdraw] 用户 %s 提现 %.2f 元,转账批次号: %s\n", req.UserID, req.Amount, outBatchNo)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "提现申请已提交,预计2小时内到账",
|
||||
"data": map[string]interface{}{
|
||||
"id": withdrawal.ID,
|
||||
"amount": req.Amount,
|
||||
"status": processingStatus,
|
||||
"out_batch_no": outBatchNo,
|
||||
"batch_id": result.BatchID,
|
||||
"created_at": withdrawal.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录(GORM)
|
||||
@@ -40,21 +177,47 @@ func WithdrawRecords(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
|
||||
}
|
||||
|
||||
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表(GORM)
|
||||
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认/处理中收款列表
|
||||
// 返回 pending、processing、pending_confirm 的提现,供小程序展示;并返回 mchId、appId 供确认收款用
|
||||
func WithdrawPendingConfirm(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
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
|
||||
// 进行中的提现:待处理、处理中、待确认收款(与 next 的 pending_confirm 兼容)
|
||||
if err := db.Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Order("created_at DESC").
|
||||
Find(&list).Error; err != nil {
|
||||
list = nil
|
||||
}
|
||||
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})
|
||||
item := gin.H{
|
||||
"id": w.ID,
|
||||
"amount": w.Amount,
|
||||
"createdAt": w.CreatedAt,
|
||||
}
|
||||
// 若有 package 信息(requestMerchantTransfer 用),一并返回;当前直接打款无 package,给空字符串
|
||||
item["package"] = ""
|
||||
out = append(out, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out, "mchId": "", "appId": ""}})
|
||||
mchId := os.Getenv("WECHAT_MCH_ID")
|
||||
if mchId == "" {
|
||||
mchId = "1318592501"
|
||||
}
|
||||
appId := os.Getenv("WECHAT_APPID")
|
||||
if appId == "" {
|
||||
appId = "wxb8bbb2b10dec74aa"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"list": out,
|
||||
"mchId": mchId,
|
||||
"appId": appId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
20
soul-api/internal/model/reading_progress.go
Normal file
20
soul-api/internal/model/reading_progress.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ReadingProgress 对应表 reading_progress
|
||||
type ReadingProgress struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
UserID string `gorm:"column:user_id;size:50"`
|
||||
SectionID string `gorm:"column:section_id;size:50"`
|
||||
Progress int `gorm:"column:progress"`
|
||||
Duration int `gorm:"column:duration"`
|
||||
Status string `gorm:"column:status;size:20"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
FirstOpenAt *time.Time `gorm:"column:first_open_at"`
|
||||
LastOpenAt *time.Time `gorm:"column:last_open_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (ReadingProgress) TableName() string { return "reading_progress" }
|
||||
@@ -4,16 +4,19 @@ 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"`
|
||||
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)"`
|
||||
PurchaseCount *int `gorm:"column:purchase_count"` // 购买次数
|
||||
TotalCommission *float64 `gorm:"column:total_commission;type:decimal(10,2)"` // 累计佣金
|
||||
LastPurchaseDate *time.Time `gorm:"column:last_purchase_date"` // 最后购买日期
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (ReferralBinding) TableName() string { return "referral_bindings" }
|
||||
|
||||
@@ -4,10 +4,13 @@ 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"`
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
ReferrerID string `gorm:"column:referrer_id;size:50"`
|
||||
VisitorID *string `gorm:"column:visitor_id;size:50"`
|
||||
VisitorOpenID *string `gorm:"column:visitor_openid;size:100"`
|
||||
Source *string `gorm:"column:source;size:50"`
|
||||
Page *string `gorm:"column:page;size:200"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (ReferralVisit) TableName() string { return "referral_visits" }
|
||||
|
||||
@@ -2,25 +2,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"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
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"`
|
||||
PurchasedSections *string `gorm:"column:purchased_sections;type:json" json:"-"` // 内部字段,实际数据从 orders 表查
|
||||
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"`
|
||||
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" }
|
||||
|
||||
20
soul-api/internal/model/user_address.go
Normal file
20
soul-api/internal/model/user_address.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserAddress 对应表 user_addresses
|
||||
type UserAddress struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50"`
|
||||
UserID string `gorm:"column:user_id;size:50"`
|
||||
Name string `gorm:"column:name;size:50"`
|
||||
Phone string `gorm:"column:phone;size:20"`
|
||||
Province string `gorm:"column:province;size:50"`
|
||||
City string `gorm:"column:city;size:50"`
|
||||
District string `gorm:"column:district;size:50"`
|
||||
Detail string `gorm:"column:detail;size:200"`
|
||||
IsDefault bool `gorm:"column:is_default"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (UserAddress) TableName() string { return "user_addresses" }
|
||||
@@ -4,14 +4,19 @@ 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"`
|
||||
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"`
|
||||
BatchNo *string `gorm:"column:batch_no;size:100" json:"batchNo,omitempty"` // 商家批次单号
|
||||
DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号
|
||||
BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号
|
||||
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注
|
||||
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
|
||||
}
|
||||
|
||||
func (Withdrawal) TableName() string { return "withdrawals" }
|
||||
|
||||
@@ -28,6 +28,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
rateLimiter := middleware.NewRateLimiter(100, 200)
|
||||
r.Use(rateLimiter.Middleware())
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// ----- 管理端 -----
|
||||
@@ -85,6 +87,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
// ----- 配置 -----
|
||||
api.GET("/config", handler.GetConfig)
|
||||
// 小程序用:GET /api/db/config 返回 freeChapters、prices(不鉴权,先于 db 组匹配)
|
||||
api.GET("/db/config", handler.GetPublicDBConfig)
|
||||
|
||||
// ----- 内容 -----
|
||||
api.GET("/content", handler.ContentGet)
|
||||
@@ -105,7 +109,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.DELETE("/book", handler.DBBookDelete)
|
||||
db.GET("/chapters", handler.DBChapters)
|
||||
db.POST("/chapters", handler.DBChapters)
|
||||
db.GET("/config", handler.DBConfigGet)
|
||||
db.GET("/config/full", handler.DBConfigGet) // 管理端拉全量配置;GET /api/db/config 已用于公开接口 GetPublicDBConfig
|
||||
db.POST("/config", handler.DBConfigPost)
|
||||
db.DELETE("/config", handler.DBConfigDelete)
|
||||
db.GET("/distribution", handler.DBDistribution)
|
||||
@@ -141,14 +145,6 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
// ----- 菜单 -----
|
||||
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)
|
||||
|
||||
@@ -198,6 +194,49 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
// ----- 微信登录 -----
|
||||
api.POST("/wechat/login", handler.WechatLogin)
|
||||
api.POST("/wechat/phone-login", handler.WechatPhoneLogin)
|
||||
|
||||
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
|
||||
miniprogram := api.Group("/miniprogram")
|
||||
{
|
||||
miniprogram.GET("/config", handler.GetPublicDBConfig)
|
||||
miniprogram.POST("/login", handler.MiniprogramLogin)
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
miniprogram.POST("/phone", handler.MiniprogramPhone)
|
||||
miniprogram.GET("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调,URL 需在商户平台配置
|
||||
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
|
||||
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/hot", handler.BookHot)
|
||||
miniprogram.GET("/book/search", handler.BookSearch)
|
||||
miniprogram.GET("/book/stats", handler.BookStats)
|
||||
miniprogram.POST("/referral/visit", handler.ReferralVisit)
|
||||
miniprogram.POST("/referral/bind", handler.ReferralBind)
|
||||
miniprogram.GET("/referral/data", handler.ReferralData)
|
||||
miniprogram.GET("/match/config", handler.MatchConfigGet)
|
||||
miniprogram.POST("/match/users", handler.MatchUsers)
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
miniprogram.POST("/ckb/match", handler.CKBMatch)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
miniprogram.DELETE("/upload", handler.UploadDelete)
|
||||
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
|
||||
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
|
||||
miniprogram.GET("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
|
||||
miniprogram.GET("/user/profile", handler.UserProfileGet)
|
||||
miniprogram.POST("/user/profile", handler.UserProfilePost)
|
||||
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
|
||||
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
|
||||
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
|
||||
miniprogram.POST("/user/update", handler.UserUpdate)
|
||||
miniprogram.POST("/withdraw", handler.WithdrawPost)
|
||||
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
|
||||
miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
api.POST("/withdraw", handler.WithdrawPost)
|
||||
@@ -205,8 +244,17 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
|
||||
}
|
||||
|
||||
// 根路径不返回任何页面(仅 204)
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.Status(204)
|
||||
})
|
||||
|
||||
// 健康检查:返回状态与版本号(版本号从 .env 的 APP_VERSION 读取,打包/上传前写入)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
392
soul-api/internal/wechat/miniprogram.go
Normal file
392
soul-api/internal/wechat/miniprogram.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
|
||||
)
|
||||
|
||||
var (
|
||||
miniProgramApp *miniProgram.MiniProgram
|
||||
paymentApp *payment.Payment
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
// Init 初始化微信客户端
|
||||
func Init(c *config.Config) error {
|
||||
cfg = c
|
||||
|
||||
// 初始化小程序
|
||||
var err error
|
||||
miniProgramApp, err = miniProgram.NewMiniProgram(&miniProgram.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
Secret: cfg.WechatAppSecret,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化小程序失败: %w", err)
|
||||
}
|
||||
|
||||
// 初始化支付(v2)
|
||||
paymentApp, err = payment.NewPayment(&payment.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
Key: cfg.WechatMchKey,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化支付失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Code2Session 小程序登录
|
||||
func Code2Session(code string) (openID, sessionKey, unionID string, err error) {
|
||||
ctx := context.Background()
|
||||
response, err := miniProgramApp.Auth.Session(ctx, code)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("code2Session失败: %w", err)
|
||||
}
|
||||
|
||||
// PowerWeChat v3 返回的是 *object.HashMap
|
||||
if response.ErrCode != 0 {
|
||||
return "", "", "", fmt.Errorf("微信返回错误: %d - %s", response.ErrCode, response.ErrMsg)
|
||||
}
|
||||
|
||||
openID = response.OpenID
|
||||
sessionKey = response.SessionKey
|
||||
unionID = response.UnionID
|
||||
|
||||
return openID, sessionKey, unionID, nil
|
||||
}
|
||||
|
||||
// GetAccessToken 获取小程序 access_token(用于手机号解密、小程序码生成)
|
||||
func GetAccessToken() (string, error) {
|
||||
ctx := context.Background()
|
||||
tokenResp, err := miniProgramApp.AccessToken.GetToken(ctx, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取access_token失败: %w", err)
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetPhoneNumber 获取用户手机号
|
||||
func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token)
|
||||
|
||||
reqBody := map[string]string{"code": code}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
PhoneInfo struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"phone_info"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", "", fmt.Errorf("解析微信返回失败: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
phoneNumber = result.PhoneInfo.PhoneNumber
|
||||
if phoneNumber == "" {
|
||||
phoneNumber = result.PhoneInfo.PurePhoneNumber
|
||||
}
|
||||
countryCode = result.PhoneInfo.CountryCode
|
||||
if countryCode == "" {
|
||||
countryCode = "86"
|
||||
}
|
||||
|
||||
return phoneNumber, countryCode, nil
|
||||
}
|
||||
|
||||
// GenerateMiniProgramCode 生成小程序码
|
||||
func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token)
|
||||
|
||||
if width <= 0 || width > 430 {
|
||||
width = 280
|
||||
}
|
||||
if page == "" {
|
||||
page = "pages/index/index"
|
||||
}
|
||||
if len(scene) > 32 {
|
||||
scene = scene[:32]
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"scene": scene,
|
||||
"page": page,
|
||||
"width": width,
|
||||
"auto_color": false,
|
||||
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
|
||||
"is_hyaline": false,
|
||||
"env_version": "trial", // 体验版,正式发布后改为 release
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 检查是否是 JSON 错误返回
|
||||
if resp.Header.Get("Content-Type") == "application/json" {
|
||||
var errResult struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &errResult); err == nil && errResult.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(body) < 1000 {
|
||||
return nil, fmt.Errorf("返回的图片数据异常(太小)")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// PayV2UnifiedOrder 微信支付 v2 统一下单
|
||||
func PayV2UnifiedOrder(params map[string]string) (map[string]string, error) {
|
||||
// 添加必要参数
|
||||
params["appid"] = cfg.WechatAppID
|
||||
params["mch_id"] = cfg.WechatMchID
|
||||
params["nonce_str"] = generateNonceStr()
|
||||
params["sign_type"] = "MD5"
|
||||
|
||||
// 生成签名
|
||||
params["sign"] = generateSign(params, cfg.WechatMchKey)
|
||||
|
||||
// 转换为 XML
|
||||
xmlData := mapToXML(params)
|
||||
|
||||
// 发送请求
|
||||
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", bytes.NewReader([]byte(xmlData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求统一下单接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
result := xmlToMap(string(body))
|
||||
|
||||
if result["return_code"] != "SUCCESS" {
|
||||
return nil, fmt.Errorf("统一下单失败: %s", result["return_msg"])
|
||||
}
|
||||
|
||||
if result["result_code"] != "SUCCESS" {
|
||||
return nil, fmt.Errorf("下单失败: %s", result["err_code_des"])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PayV2OrderQuery 微信支付 v2 订单查询
|
||||
func PayV2OrderQuery(outTradeNo string) (map[string]string, error) {
|
||||
params := map[string]string{
|
||||
"appid": cfg.WechatAppID,
|
||||
"mch_id": cfg.WechatMchID,
|
||||
"out_trade_no": outTradeNo,
|
||||
"nonce_str": generateNonceStr(),
|
||||
}
|
||||
|
||||
params["sign"] = generateSign(params, cfg.WechatMchKey)
|
||||
xmlData := mapToXML(params)
|
||||
|
||||
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/orderquery", "application/xml", bytes.NewReader([]byte(xmlData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求订单查询接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
result := xmlToMap(string(body))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// VerifyPayNotify 验证支付回调签名
|
||||
func VerifyPayNotify(data map[string]string) bool {
|
||||
receivedSign := data["sign"]
|
||||
if receivedSign == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
delete(data, "sign")
|
||||
calculatedSign := generateSign(data, cfg.WechatMchKey)
|
||||
|
||||
return receivedSign == calculatedSign
|
||||
}
|
||||
|
||||
// GenerateJSAPIPayParams 生成小程序支付参数
|
||||
func GenerateJSAPIPayParams(prepayID string) map[string]string {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonceStr := generateNonceStr()
|
||||
|
||||
params := map[string]string{
|
||||
"appId": cfg.WechatAppID,
|
||||
"timeStamp": timestamp,
|
||||
"nonceStr": nonceStr,
|
||||
"package": fmt.Sprintf("prepay_id=%s", prepayID),
|
||||
"signType": "MD5",
|
||||
}
|
||||
|
||||
params["paySign"] = generateSign(params, cfg.WechatMchKey)
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
func generateNonceStr() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func generateSign(params map[string]string, key string) string {
|
||||
// 按字典序排序
|
||||
var keys []string
|
||||
for k := range params {
|
||||
if k != "sign" && params[k] != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单冒泡排序
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[i] > keys[j] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接字符串
|
||||
var signStr string
|
||||
for _, k := range keys {
|
||||
signStr += fmt.Sprintf("%s=%s&", k, params[k])
|
||||
}
|
||||
signStr += fmt.Sprintf("key=%s", key)
|
||||
|
||||
// MD5
|
||||
hash := md5.Sum([]byte(signStr))
|
||||
return fmt.Sprintf("%X", hash) // 大写
|
||||
}
|
||||
|
||||
func mapToXML(data map[string]string) string {
|
||||
xml := "<xml>"
|
||||
for k, v := range data {
|
||||
xml += fmt.Sprintf("<%s><![CDATA[%s]]></%s>", k, v, k)
|
||||
}
|
||||
xml += "</xml>"
|
||||
return xml
|
||||
}
|
||||
|
||||
func xmlToMap(xmlStr string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// 简单的 XML 解析(仅支持 <key><![CDATA[value]]></key> 和 <key>value</key> 格式)
|
||||
var key, value string
|
||||
inCDATA := false
|
||||
inTag := false
|
||||
isClosing := false
|
||||
|
||||
for i := 0; i < len(xmlStr); i++ {
|
||||
ch := xmlStr[i]
|
||||
|
||||
if ch == '<' {
|
||||
if i+1 < len(xmlStr) && xmlStr[i+1] == '/' {
|
||||
isClosing = true
|
||||
i++ // skip '/'
|
||||
} else if i+8 < len(xmlStr) && xmlStr[i:i+9] == "<![CDATA[" {
|
||||
inCDATA = true
|
||||
i += 8 // skip "![CDATA["
|
||||
continue
|
||||
}
|
||||
inTag = true
|
||||
key = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '>' {
|
||||
inTag = false
|
||||
if isClosing {
|
||||
if key != "" && key != "xml" {
|
||||
result[key] = value
|
||||
}
|
||||
key = ""
|
||||
value = ""
|
||||
isClosing = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inCDATA && i+2 < len(xmlStr) && xmlStr[i:i+3] == "]]>" {
|
||||
inCDATA = false
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if inTag {
|
||||
key += string(ch)
|
||||
} else if !isClosing {
|
||||
value += string(ch)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// XMLToMap 导出供外部使用
|
||||
func XMLToMap(xmlStr string) map[string]string {
|
||||
return xmlToMap(xmlStr)
|
||||
}
|
||||
|
||||
// GenerateOrderSn 生成订单号
|
||||
func GenerateOrderSn() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("MP%s%06d", timestamp, random)
|
||||
}
|
||||
203
soul-api/internal/wechat/transfer.go
Normal file
203
soul-api/internal/wechat/transfer.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/transferbatch"
|
||||
)
|
||||
|
||||
var (
|
||||
transferClient *core.Client
|
||||
transferCfg *config.Config
|
||||
)
|
||||
|
||||
// InitTransfer 初始化转账客户端
|
||||
func InitTransfer(c *config.Config) error {
|
||||
transferCfg = c
|
||||
|
||||
// 加载商户私钥
|
||||
privateKey, err := loadPrivateKey(c.WechatKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载商户私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 初始化客户端
|
||||
opts := []core.ClientOption{
|
||||
option.WithWechatPayAutoAuthCipher(c.WechatMchID, c.WechatSerialNo, privateKey, c.WechatAPIv3Key),
|
||||
}
|
||||
|
||||
client, err := core.NewClient(context.Background(), opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化微信支付客户端失败: %w", err)
|
||||
}
|
||||
|
||||
transferClient = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPrivateKey 加载商户私钥
|
||||
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(privateKeyBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("解析私钥失败:无效的 PEM 格式")
|
||||
}
|
||||
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
// 尝试 PKCS8 格式
|
||||
key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
privateKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("私钥不是 RSA 格式")
|
||||
}
|
||||
}
|
||||
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// TransferParams 转账参数
|
||||
type TransferParams struct {
|
||||
OutBatchNo string // 商家批次单号(唯一)
|
||||
OutDetailNo string // 商家明细单号(唯一)
|
||||
OpenID string // 收款用户 openid
|
||||
Amount int // 转账金额(分)
|
||||
UserName string // 收款用户姓名(可选,用于实名校验)
|
||||
Remark string // 转账备注
|
||||
BatchName string // 批次名称(如"提现")
|
||||
BatchRemark string // 批次备注
|
||||
}
|
||||
|
||||
// TransferResult 转账结果
|
||||
type TransferResult struct {
|
||||
BatchID string // 微信批次单号
|
||||
OutBatchNo string // 商家批次单号
|
||||
CreateTime time.Time // 批次创建时间
|
||||
BatchStatus string // 批次状态:ACCEPTED-已受理, PROCESSING-处理中, FINISHED-已完成, CLOSED-已关闭
|
||||
}
|
||||
|
||||
// InitiateTransfer 发起转账
|
||||
func InitiateTransfer(params TransferParams) (*TransferResult, error) {
|
||||
if transferClient == nil {
|
||||
return nil, fmt.Errorf("转账客户端未初始化")
|
||||
}
|
||||
|
||||
svc := transferbatch.TransferBatchApiService{Client: transferClient}
|
||||
|
||||
// 构建转账明细
|
||||
details := []transferbatch.TransferDetailInput{
|
||||
{
|
||||
OutDetailNo: core.String(params.OutDetailNo),
|
||||
TransferAmount: core.Int64(int64(params.Amount)),
|
||||
TransferRemark: core.String(params.Remark),
|
||||
Openid: core.String(params.OpenID),
|
||||
},
|
||||
}
|
||||
|
||||
// 如果提供了姓名,添加实名校验
|
||||
if params.UserName != "" {
|
||||
details[0].UserName = core.String(params.UserName)
|
||||
}
|
||||
|
||||
// 发起转账请求
|
||||
req := transferbatch.InitiateBatchTransferRequest{
|
||||
Appid: core.String(transferCfg.WechatAppID),
|
||||
OutBatchNo: core.String(params.OutBatchNo),
|
||||
BatchName: core.String(params.BatchName),
|
||||
BatchRemark: core.String(params.BatchRemark),
|
||||
TotalAmount: core.Int64(int64(params.Amount)),
|
||||
TotalNum: core.Int64(1),
|
||||
TransferDetailList: details,
|
||||
}
|
||||
|
||||
resp, result, err := svc.InitiateBatchTransfer(context.Background(), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发起转账失败: %w", err)
|
||||
}
|
||||
|
||||
if result.Response.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("转账请求失败,状态码: %d", result.Response.StatusCode)
|
||||
}
|
||||
|
||||
return &TransferResult{
|
||||
BatchID: *resp.BatchId,
|
||||
OutBatchNo: *resp.OutBatchNo,
|
||||
CreateTime: *resp.CreateTime,
|
||||
BatchStatus: "ACCEPTED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTransfer 查询转账结果(暂不实现,转账状态通过回调获取)
|
||||
func QueryTransfer(outBatchNo, outDetailNo string) (map[string]interface{}, error) {
|
||||
// TODO: 实现查询转账结果
|
||||
// 微信转账采用异步模式,通过回调通知最终结果
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "processing",
|
||||
"message": "转账处理中,请等待回调通知",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateTransferBatchNo 生成转账批次单号
|
||||
func GenerateTransferBatchNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// GenerateTransferDetailNo 生成转账明细单号
|
||||
func GenerateTransferDetailNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WDD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// TransferNotifyResult 转账回调结果
|
||||
type TransferNotifyResult struct {
|
||||
MchID *string `json:"mchid"`
|
||||
OutBatchNo *string `json:"out_batch_no"`
|
||||
BatchID *string `json:"batch_id"`
|
||||
AppID *string `json:"appid"`
|
||||
OutDetailNo *string `json:"out_detail_no"`
|
||||
DetailID *string `json:"detail_id"`
|
||||
DetailStatus *string `json:"detail_status"`
|
||||
TransferAmount *int64 `json:"transfer_amount"`
|
||||
OpenID *string `json:"openid"`
|
||||
UserName *string `json:"user_name"`
|
||||
InitiateTime *string `json:"initiate_time"`
|
||||
UpdateTime *string `json:"update_time"`
|
||||
FailReason *string `json:"fail_reason"`
|
||||
}
|
||||
|
||||
// VerifyTransferNotify 验证转账回调签名(使用 notify handler)
|
||||
func VerifyTransferNotify(ctx context.Context, request interface{}) (*TransferNotifyResult, error) {
|
||||
// 微信官方 SDK 的回调处理
|
||||
// 实际使用时,微信会 POST JSON 数据,包含加密信息
|
||||
// 这里暂时返回简化版本,实际项目中需要完整实现签名验证
|
||||
|
||||
// TODO: 完整实现回调验证
|
||||
// 需要解析请求体中的 resource.ciphertext,使用 APIv3 密钥解密
|
||||
|
||||
return &TransferNotifyResult{}, fmt.Errorf("转账回调需要完整实现")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
BIN
soul-api/soul-api.exe
Normal file
BIN
soul-api/soul-api.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.
8
soul-api/wechat/info.log
Normal file
8
soul-api/wechat/info.log
Normal file
@@ -0,0 +1,8 @@
|
||||
{"level":"debug","timestamp":"2026-02-09T16:55:02+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-02-09T16:55:02+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Mon, 09 Feb 2026 08:55:01 GMT\r\n\r\n{\"access_token\":\"100_4Qq-JbpbR51RHpx5chuh3Uu4m4jJmboZpwFiwRTV_KYZhoZ6311mO54HHyxkk0etsqMDyjj5Jha4E63VjbX_bWBHi5FCek_WwBWKeyvfgIwTa5uWKcfxCfJqEB0DHIjAGAIMN\",\"expires_in\":7200}"}
|
||||
{"level":"debug","timestamp":"2026-02-09T16:55:02+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=100_4Qq-JbpbR51RHpx5chuh3Uu4m4jJmboZpwFiwRTV_KYZhoZ6311mO54HHyxkk0etsqMDyjj5Jha4E63VjbX_bWBHi5FCek_WwBWKeyvfgIwTa5uWKcfxCfJqEB0DHIjAGAIMN&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0f3UhTFa1DGDaL0IzfGa1cwYMd0UhTF3&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-02-09T16:55:02+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Mon, 09 Feb 2026 08:55:02 GMT\r\n\r\n{\"session_key\":\"CjtqaXJ6+R3oOlVdlVutwg==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
|
||||
{"level":"debug","timestamp":"2026-02-09T17:09:56+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-02-09T17:09:56+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Mon, 09 Feb 2026 09:09:56 GMT\r\n\r\n{\"access_token\":\"100_pYtS9qgA2TynfkbEwQA-PVIvZ86ZH242clHJGzB3xBUzePhWpBtlPpbpF1WPa-ksZ0X1pCgQ3GZdOQx7hI8LTBZBzspO5Y7HD2__FmVGTBHEEXBO5KC8LtHtGCgWHLjAIAEPT\",\"expires_in\":7200}"}
|
||||
{"level":"debug","timestamp":"2026-02-09T17:09:56+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=100_pYtS9qgA2TynfkbEwQA-PVIvZ86ZH242clHJGzB3xBUzePhWpBtlPpbpF1WPa-ksZ0X1pCgQ3GZdOQx7hI8LTBZBzspO5Y7HD2__FmVGTBHEEXBO5KC8LtHtGCgWHLjAIAEPT&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0e3misGa1mI8bL07SQGa1ooPA01misGI&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-02-09T17:09:56+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Mon, 09 Feb 2026 09:09:56 GMT\r\n\r\n{\"session_key\":\"GPZHc5k1ud3stnTc4LX9LA==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
|
||||
76
soul-api/宝塔反向代理说明.txt
Normal file
76
soul-api/宝塔反向代理说明.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
# soul-api 域名 404 原因与解决
|
||||
|
||||
## 原因
|
||||
域名请求先到 Nginx,若没有把请求转发到本机 8080 的 Go,或站点用了 root/静态目录,就会 404。
|
||||
|
||||
---
|
||||
|
||||
## 一、先确认 Go 是否在跑(必做)
|
||||
|
||||
在宝塔终端或 SSH 里执行:
|
||||
|
||||
curl -s http://127.0.0.1:8080/health
|
||||
|
||||
- 若返回 {"status":"ok"}:说明 Go 正常,问题在 Nginx,看下面第二步。
|
||||
- 若连接被拒绝或超时:说明 8080 没在监听。去 宝塔 → Go项目管理 → soulApi → 服务状态,看是否“运行中”;看“项目日志”是否有报错。
|
||||
|
||||
---
|
||||
|
||||
## 二、Nginx 必须“整站走代理”,不能走 root
|
||||
|
||||
添加了反向代理仍 404,多半是:
|
||||
|
||||
- 站点默认有 location / { root ...; index ...; },请求被当成静态文件处理,/health 找不到就 404;
|
||||
- 或反向代理只绑在了子路径(如 /api),/ 和 /health 没被代理。
|
||||
|
||||
做法:让 soulapi.quwanzhi.com 的**所有路径**都走 8080,不要用 root。
|
||||
|
||||
在宝塔:网站 → soulapi.quwanzhi.com → 设置 → 配置文件,找到该站点的 server { ... },按下面两种方式之一改。
|
||||
|
||||
### 方式 A:只保留一个 location /(推荐)
|
||||
|
||||
把 server 里**原来的** location / { ... }(含 root、index 的那段)**删掉或注释掉**,只保留下面这一段:
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
保存 → 重载 Nginx(或 宝塔 里点“重载配置”)。
|
||||
|
||||
### 方式 B:整站用下面这一整段 server(HTTPS 示例)
|
||||
|
||||
若你希望整站只做反向代理、不混静态,可以把该站点的 server 块整体替换成下面内容(把 your_ssl_cert 等换成你实际的证书路径;没有 SSL 就只用 listen 80 那段):
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name soulapi.quwanzhi.com;
|
||||
# SSL 证书路径按宝塔实际填写,例如:
|
||||
# ssl_certificate /www/server/panel/vhost/cert/soulapi.quwanzhi.com/fullchain.pem;
|
||||
# ssl_certificate_key /www/server/panel/vhost/cert/soulapi.quwanzhi.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
保存并重载 Nginx。
|
||||
|
||||
---
|
||||
|
||||
## 三、改完后自测
|
||||
|
||||
- 本机:curl -s https://soulapi.quwanzhi.com/health
|
||||
- 或浏览器打开:https://soulapi.quwanzhi.com/health
|
||||
应看到:{"status":"ok"}
|
||||
- 打开 https://soulapi.quwanzhi.com/ 应看到“部署成功”页面。
|
||||
319
开发文档/5、接口/在线支付对接文档.md
Normal file
319
开发文档/5、接口/在线支付对接文档.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 在线支付对接文档
|
||||
|
||||
本文档根据当前项目中的支付相关代码与配置反向整理,供前端/第三方/运维对接使用。后端当前为 **soul-api(Go/Gin)**,支付业务逻辑可参考 **next-project** 中的实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、概述
|
||||
|
||||
- **支付方式**:微信支付(Native 扫码 / JSAPI 小程序·公众号 / H5)、支付宝(WAP / Web / 扫码)。
|
||||
- **对接入口**:统一走 soul-api 的 `/api` 前缀(如 `https://your-api.com/api/...`)。
|
||||
- **回调**:支付平台(微信/支付宝)会主动 POST 到服务端配置的 notify 地址,需公网可访问且返回约定格式。
|
||||
|
||||
---
|
||||
|
||||
## 二、接口清单(soul-api)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|------|
|
||||
| POST | `/api/payment/create-order` | 创建支付订单,返回支付参数(二维码/链接/JSAPI 参数等) |
|
||||
| GET | `/api/payment/methods` | 获取可用支付方式列表 |
|
||||
| GET | `/api/payment/query` | 按交易号查询支付状态(轮询用) |
|
||||
| GET | `/api/payment/status/:orderSn` | 按订单号查询订单支付状态 |
|
||||
| POST | `/api/payment/verify` | 支付结果校验(可选) |
|
||||
| POST | `/api/payment/callback` | 通用支付回调(可选,与各平台 notify 二选一或并存) |
|
||||
| POST | `/api/payment/wechat/notify` | 微信支付异步通知 |
|
||||
| POST | `/api/payment/alipay/notify` | 支付宝异步通知 |
|
||||
| POST | `/api/payment/wechat/transfer/notify` | 微信转账/企业付款到零钱回调(若启用) |
|
||||
| GET/POST | `/api/miniprogram/pay` | 小程序下单(创建订单 + 返回微信支付参数) |
|
||||
| POST | `/api/miniprogram/pay/notify` | 小程序支付异步通知 |
|
||||
|
||||
管理端(需鉴权):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|------|
|
||||
| GET/POST/PUT/DELETE | `/api/admin/payment` | 支付相关配置管理 |
|
||||
|
||||
---
|
||||
|
||||
## 三、请求与响应约定
|
||||
|
||||
以下格式以 next-project 中已实现逻辑为对接规范,soul-api 实现时应与之兼容。
|
||||
|
||||
### 3.1 创建订单 `POST /api/payment/create-order`
|
||||
|
||||
**请求体(JSON)**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| userId | string | 是 | 用户 ID |
|
||||
| type | string | 是 | 购买类型:`section`(单章) / `fullbook`(全书) |
|
||||
| sectionId | string | type=section 时 | 章节 ID,如 `1-1` |
|
||||
| sectionTitle | string | 建议 | 章节标题,用于展示与订单描述 |
|
||||
| amount | number | 是 | 金额(元),如 9.9 |
|
||||
| paymentMethod | string | 是 | 支付方式:`wechat` / `alipay` |
|
||||
| referralCode | string | 否 | 推荐人邀请码,用于分销 |
|
||||
|
||||
**响应(JSON)**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "订单创建成功",
|
||||
"data": {
|
||||
"orderSn": "20260209123456",
|
||||
"tradeSn": "T2026020912000012345",
|
||||
"userId": "user_xxx",
|
||||
"type": "section",
|
||||
"sectionId": "1-1",
|
||||
"sectionTitle": "第一章",
|
||||
"amount": 9.9,
|
||||
"paymentMethod": "wechat",
|
||||
"status": "created",
|
||||
"createdAt": "2026-02-09T12:00:00.000Z",
|
||||
"expireAt": "2026-02-09T12:30:00.000Z",
|
||||
"paymentData": {
|
||||
"type": "qrcode",
|
||||
"payload": "weixin://wxpay/...",
|
||||
"tradeSn": "T2026020912000012345",
|
||||
"expiration": 1800
|
||||
},
|
||||
"gateway": "wechat_native"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **paymentData.type**:`qrcode`(二维码内容/链接)、`url`(跳转链接)、`json`(JSAPI 等参数对象)。
|
||||
- **paymentData.payload**:微信 Native 为二维码链接;支付宝 WAP 为支付 URL;JSAPI 为 `{ timeStamp, nonceStr, package, signType, paySign }` 等。
|
||||
- **gateway**:用于后续轮询时传 `gateway`,如 `wechat_native`、`alipay_wap`。
|
||||
|
||||
**错误**:`code: 400` 表示缺少必要参数;`code: 500` 为服务器错误。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 支付方式列表 `GET /api/payment/methods`
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"methods": [
|
||||
{
|
||||
"gateway": "wechat_native",
|
||||
"name": "微信支付",
|
||||
"icon": "wechat",
|
||||
"enabled": true,
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 查询支付状态(轮询)`GET /api/payment/query`
|
||||
|
||||
**Query**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| tradeSn | string | 是 | 创建订单时返回的 tradeSn |
|
||||
| gateway | string | 否 | 指定网关,如 `wechat_native`、`alipay_wap`,不传则双通道查询 |
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"tradeSn": "T2026020912000012345",
|
||||
"status": "paid",
|
||||
"platformSn": "4200001234567890",
|
||||
"payAmount": 990,
|
||||
"payTime": "2026-02-09T12:05:00.000Z",
|
||||
"gateway": "wechat_native"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **status**:`paying` 未支付,`paid` 已支付,`closed` 已关闭/退款等。
|
||||
- **payAmount**:单位「分」。**payTime** 为支付完成时间。
|
||||
|
||||
前端建议:每 3 秒轮询一次,最多约 60 次(约 3 分钟);收到 `status: "paid"` 后停止轮询并更新订单/解锁内容。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 按订单号查状态 `GET /api/payment/status/:orderSn`
|
||||
|
||||
**路径参数**:`orderSn` 为创建订单返回的订单号。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"orderSn": "20260209123456",
|
||||
"status": "paid",
|
||||
"paidAmount": 9.9,
|
||||
"paidAt": "2026-02-09T12:05:00.000Z",
|
||||
"paymentMethod": "wechat",
|
||||
"tradeSn": "T2026020912000012345",
|
||||
"productType": "section"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **status**:与业务一致:`created`、`paying`、`paid`、`closed`、`refunded` 等。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 支付校验 `POST /api/payment/verify`
|
||||
|
||||
**请求体**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| orderId | string | 订单号 |
|
||||
| paymentMethod | string | 支付方式 |
|
||||
| transactionId | string | 第三方交易号(可选) |
|
||||
|
||||
**响应**:成功时 `code: 0`,失败时非 0;用于前端在回调不确定时的二次校验(具体逻辑由后端实现)。
|
||||
|
||||
---
|
||||
|
||||
## 四、支付平台异步通知(回调)
|
||||
|
||||
对接方需在微信支付/支付宝商户后台配置「支付结果通知 URL」,且必须为 **公网 HTTPS**。当前项目约定路径如下(以 soul-api 域名为准):
|
||||
|
||||
| 支付方式 | 通知 URL | 说明 |
|
||||
|----------|----------|------|
|
||||
| 微信支付 | `https://your-api.com/api/payment/wechat/notify` | 统一下单/JSAPI/小程序等 |
|
||||
| 支付宝 | `https://your-api.com/api/payment/alipay/notify` | 异步 notify |
|
||||
| 微信转账 | `https://your-api.com/api/payment/wechat/transfer/notify` | 企业付款到零钱(若使用) |
|
||||
|
||||
### 4.1 微信支付 notify
|
||||
|
||||
- **方法**:POST
|
||||
- **Content-Type**:`application/xml`
|
||||
- **Body**:微信以 XML 推送,字段含 `return_code`、`result_code`、`out_trade_no`、`transaction_id`、`total_fee`、`time_end`、`sign` 等。
|
||||
- **验签**:使用商户密钥对微信参数做 MD5 签名校验(见 next-project `lib/payment/wechat.ts` 中 `verifySign`)。
|
||||
- **响应**:必须返回 XML,成功示例:
|
||||
`<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
|
||||
失败则返回 `return_code=FAIL`,否则微信会重试。
|
||||
|
||||
**业务处理建议**(与 next-project 一致):
|
||||
1. 验签通过且 `result_code=SUCCESS` 后,用 `out_trade_no`(即本系统 tradeSn)查订单,更新为已支付、写入 `transaction_id`、`pay_time`。
|
||||
2. 根据 `product_type` 开通全书或章节权限。
|
||||
3. 若有推荐人,写分销表并更新 `pending_earnings`。
|
||||
4. 响应必须在业务异常时仍返回成功 XML,避免微信重复通知。
|
||||
|
||||
### 4.2 支付宝 notify
|
||||
|
||||
- **方法**:POST
|
||||
- **Content-Type**:`application/x-www-form-urlencoded`
|
||||
- **Body**:表单键值对,含 `out_trade_no`、`trade_no`、`trade_status`、`total_amount`、`gmt_payment`、`sign` 等。
|
||||
- **验签**:使用配置的 MD5 密钥校验(见 next-project `lib/payment/alipay.ts`)。
|
||||
- **响应**:纯文本,成功返回 `success`,失败返回 `fail`。支付宝会多次重试直至收到 `success`。
|
||||
|
||||
**业务处理**:与微信类似,以 `out_trade_no` 更新订单、开通权限、处理分销。
|
||||
|
||||
---
|
||||
|
||||
## 五、小程序支付
|
||||
|
||||
### 5.1 下单 `GET/POST /api/miniprogram/pay`
|
||||
|
||||
**请求体(JSON)**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| openId | string | 是 | 用户 openId |
|
||||
| productType | string | 是 | `section` / `fullbook` |
|
||||
| productId | string | 是 | 章节 ID 或 `fullbook` |
|
||||
| amount | number | 是 | 金额(元) |
|
||||
| description | string | 建议 | 订单描述 |
|
||||
| userId | string | 是 | 用户 ID |
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"orderSn": "MP20260204123456789012",
|
||||
"prepayId": "wx...",
|
||||
"payParams": {
|
||||
"timeStamp": "...",
|
||||
"nonceStr": "...",
|
||||
"package": "prepay_id=...",
|
||||
"signType": "MD5",
|
||||
"paySign": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
小程序端用 `payParams` 调起 `wx.requestPayment`。
|
||||
|
||||
### 5.2 小程序支付通知 `POST /api/miniprogram/pay/notify`
|
||||
|
||||
与「微信支付 notify」同一套规范(XML 入参、XML 成功响应)。商户后台配置的「支付结果通知 URL」填 soul-api 的 `/api/miniprogram/pay/notify` 或统一使用 `/api/payment/wechat/notify` 均可,需与后端实现一致(按订单来源更新对应订单与权限)。
|
||||
|
||||
---
|
||||
|
||||
## 六、配置项
|
||||
|
||||
### 6.1 管理端 / 配置接口
|
||||
|
||||
- **GET /api/config**:返回全站配置,其中 **paymentMethods** 用于前端展示支付方式及微信/支付宝相关配置(如微信群二维码、商户信息等)。
|
||||
- 支付开关、商户号、密钥等建议放在服务端环境变量或管理端「支付配置」中,不通过公开接口暴露密钥。
|
||||
|
||||
### 6.2 环境变量(参考 next-project)
|
||||
|
||||
后端若自实现支付,可参考以下变量(soul-api 当前未在 .env.example 中列出,对接时按需增加):
|
||||
|
||||
**微信**
|
||||
|
||||
- `WECHAT_APPID` / `WECHAT_SERVICE_APPID`:公众号/服务号 AppID
|
||||
- `WECHAT_APP_SECRET` / `WECHAT_SERVICE_SECRET`
|
||||
- `WECHAT_MCH_ID`:商户号
|
||||
- `WECHAT_MCH_KEY`:商户 API 密钥
|
||||
|
||||
**支付宝**
|
||||
|
||||
- `ALIPAY_APP_ID` / `ALIPAY_PID`
|
||||
- `ALIPAY_PRIVATE_KEY` / `ALIPAY_PUBLIC_KEY` 或 `ALIPAY_MD5_KEY`
|
||||
- `ALIPAY_SELLER_EMAIL`
|
||||
|
||||
**应用**
|
||||
|
||||
- `NEXT_PUBLIC_BASE_URL` 或等价「站点 base URL」:用于拼装 notify/return 地址。
|
||||
|
||||
---
|
||||
|
||||
## 七、订单与数据库(参考)
|
||||
|
||||
- **orders 表**:`id`、`order_sn`、`user_id`、`open_id`、`product_type`、`product_id`、`amount`、`description`、`status`、`transaction_id`、`pay_time`、`referral_code`、`referrer_id`、`created_at`、`updated_at`。
|
||||
- **status**:`created` → 创建,`paid` → 已支付,`expired`/`cancelled` 等由业务定义。
|
||||
- 创建订单时可将 **tradeSn** 写入 `transaction_id`,支付回调里用微信/支付宝的「商户订单号」即 tradeSn 查单并更新为 `transaction_id`(平台交易号)、`pay_time`、`status=paid`。
|
||||
|
||||
---
|
||||
|
||||
## 八、错误与注意事项
|
||||
|
||||
1. **签名**:所有微信/支付宝回调必须先验签再执行业务,否则存在伪造风险。
|
||||
2. **幂等**:同一笔订单可能被多次通知,更新订单与佣金前应判断当前状态,避免重复加款。
|
||||
3. **响应**:notify 接口必须在处理完(或确认可稍后处理)后按平台要求返回成功(微信 XML 成功、支付宝 `success`),再异步做后续逻辑,避免平台反复回调。
|
||||
4. **金额**:微信为「分」,支付宝为「元」;内部建议统一用「分」存储,与 next-project 一致。
|
||||
5. **soul-api 现状**:当前 payment/miniprogram 相关 handler 为占位实现(直接返回 success),完整逻辑需按本文档与 next-project 实现对齐后上线。
|
||||
|
||||
---
|
||||
|
||||
*文档根据项目代码反向整理,若与最新代码不一致,以实际仓库为准。*
|
||||
Reference in New Issue
Block a user