更新小程序API路径,统一为/api/miniprogram前缀,确保与后端一致性。同时,调整微信支付相关配置,增强系统的灵活性和可维护性。

This commit is contained in:
乘风
2026-02-09 18:19:12 +08:00
parent 7b2123dfe5
commit e6aebeeca5
59 changed files with 5040 additions and 179 deletions

View File

@@ -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) {

View File

@@ -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'
})

View File

@@ -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,

View File

@@ -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'
})

View File

@@ -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
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {
// 更新全局购买状态

View File

@@ -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 }
})

View File

@@ -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({

View File

@@ -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 }
})

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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
View 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 → dist1dist2 → dist ====================
def remote_swap_dist(cfg):
"""服务器上dist→dist1dist2→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
View 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.gzdeploy 模式用)"""
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_pathdeploy 模式)"""
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 为 zipdevlop 模式用)"""
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 并解压到 dist2devlop 模式)"""
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 已上传,正在服务器解压(约 13 分钟)...")
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())

View File

@@ -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
}
/** 请求完整 URLbaseUrl + pathpath 必须与现网一致(如 /api/orders */

View File

@@ -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 })

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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

View File

@@ -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

Binary file not shown.

View 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-----

View 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-----

View File

@@ -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{

View File

@@ -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_projectPOST 带 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:

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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),
})
}

View File

@@ -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()
}

View File

@@ -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"})
}

View File

@@ -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
}

View File

@@ -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": "删除成功"})
}

View File

@@ -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": "更新成功"})
}

View File

@@ -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() 的 codephoneCode 为 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
}

View File

@@ -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,
},
})
}

View 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" }

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -2,25 +2,26 @@ package model
import "time"
// User 对应表 usersJSON 输出与现网接口 1:1小写驼峰
type User struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
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" }

View 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" }

View File

@@ -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" }

View File

@@ -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

View 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)
}

View 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

Binary file not shown.

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

Binary file not shown.

8
soul-api/wechat/info.log Normal file
View 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\"}"}

View 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整站用下面这一整段 serverHTTPS 示例)
若你希望整站只做反向代理、不混静态,可以把该站点的 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/ 应看到“部署成功”页面。

View File

@@ -0,0 +1,319 @@
# 在线支付对接文档
本文档根据当前项目中的支付相关代码与配置反向整理,供前端/第三方/运维对接使用。后端当前为 **soul-apiGo/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 为支付 URLJSAPI 为 `{ 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 实现对齐后上线。
---
*文档根据项目代码反向整理,若与最新代码不一致,以实际仓库为准。*