diff --git a/miniprogram/app.js b/miniprogram/app.js index 7faa7ae4..5fd7afa3 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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) { diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js index 2ccba866..4acd9546 100644 --- a/miniprogram/custom-tab-bar/index.js +++ b/miniprogram/custom-tab-bar/index.js @@ -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' }) diff --git a/miniprogram/pages/about/about.js b/miniprogram/pages/about/about.js index dbbcabb0..8f19cc60 100644 --- a/miniprogram/pages/about/about.js +++ b/miniprogram/pages/about/about.js @@ -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, diff --git a/miniprogram/pages/addresses/addresses.js b/miniprogram/pages/addresses/addresses.js index 0fc71b09..685528cf 100644 --- a/miniprogram/pages/addresses/addresses.js +++ b/miniprogram/pages/addresses/addresses.js @@ -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' }) diff --git a/miniprogram/pages/addresses/edit.js b/miniprogram/pages/addresses/edit.js index 69f08cf2..9542c1dc 100644 --- a/miniprogram/pages/addresses/edit.js +++ b/miniprogram/pages/addresses/edit.js @@ -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 }) diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 1cc182bb..bd292148 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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, diff --git a/miniprogram/pages/match/match.js b/miniprogram/pages/match/match.js index 7e0007d3..fe9d7b4d 100644 --- a/miniprogram/pages/match/match.js +++ b/miniprogram/pages/match/match.js @@ -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, diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 7294b2b5..b847e424 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -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, diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 6b59a99e..0a015411 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -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) { // 更新全局购买状态 diff --git a/miniprogram/pages/referral/referral.js b/miniprogram/pages/referral/referral.js index 2a005f36..45415511 100644 --- a/miniprogram/pages/referral/referral.js +++ b/miniprogram/pages/referral/referral.js @@ -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 } }) diff --git a/miniprogram/pages/search/search.js b/miniprogram/pages/search/search.js index 3bafcb8c..1ac887d9 100644 --- a/miniprogram/pages/search/search.js +++ b/miniprogram/pages/search/search.js @@ -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({ diff --git a/miniprogram/pages/settings/settings.js b/miniprogram/pages/settings/settings.js index 18cfc482..157af2f0 100644 --- a/miniprogram/pages/settings/settings.js +++ b/miniprogram/pages/settings/settings.js @@ -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 } }) diff --git a/miniprogram/pages/withdraw-records/withdraw-records.js b/miniprogram/pages/withdraw-records/withdraw-records.js index a50b58fd..9b851f9f 100644 --- a/miniprogram/pages/withdraw-records/withdraw-records.js +++ b/miniprogram/pages/withdraw-records/withdraw-records.js @@ -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, diff --git a/miniprogram/utils/chapterAccessManager.js b/miniprogram/utils/chapterAccessManager.js index cade9fd1..4bafe737 100644 --- a/miniprogram/utils/chapterAccessManager.js +++ b/miniprogram/utils/chapterAccessManager.js @@ -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 diff --git a/miniprogram/utils/readingTracker.js b/miniprogram/utils/readingTracker.js index bb077c76..a52fb896 100644 --- a/miniprogram/utils/readingTracker.js +++ b/miniprogram/utils/readingTracker.js @@ -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, diff --git a/soul-admin/.env.development b/soul-admin/.env.development index 32cfa9c1..d75aede3 100644 --- a/soul-admin/.env.development +++ b/soul-admin/.env.development @@ -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 diff --git a/soul-admin/deploy_admin.py b/soul-admin/deploy_admin.py new file mode 100644 index 00000000..08a8abb2 --- /dev/null +++ b/soul-admin/deploy_admin.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +soul-admin 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。 +不安装依赖、不重启、不调用宝塔 API。 +""" + +from __future__ import print_function + +import os +import sys +import tempfile +import argparse +import zipfile + +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +# ==================== 配置 ==================== + +# 站点根目录(Nginx 等指向的目录的上一级,即 dist 的父目录) +DEPLOY_BASE_PATH = "/www/wwwroot/soulAdmin.quwanzhi.com" +DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) + + +def get_cfg(): + return { + "host": os.environ.get("DEPLOY_HOST", "43.139.27.93"), + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "base_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/"), + "dist_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/") + "/dist", + "dist2_path": os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/") + "/dist2", + } + + +# ==================== 本地构建 ==================== + + +def run_build(root): + """执行本地 pnpm build""" + use_shell = sys.platform == "win32" + dist_dir = os.path.join(root, "dist") + index_html = os.path.join(dist_dir, "index.html") + + try: + r = __import__("subprocess").run( + ["pnpm", "build"], + cwd=root, + shell=use_shell, + timeout=300, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r.returncode != 0: + print(" [失败] 构建失败,退出码:", r.returncode) + for line in (r.stdout or "").strip().split("\n")[-10:]: + print(" " + line) + return False + except __import__("subprocess").TimeoutExpired: + print(" [失败] 构建超时") + return False + except FileNotFoundError: + print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm") + return False + except Exception as e: + print(" [失败] 构建异常:", str(e)) + return False + + if not os.path.isfile(index_html): + print(" [失败] 未找到 dist/index.html") + return False + print(" [成功] 构建完成") + return True + + +# ==================== 打包 dist 为 zip ==================== + + +def pack_dist_zip(root): + """将本地 dist 目录打包为 zip(解压到 dist2 后即为站点根内容)""" + print("[2/4] 打包 dist 为 zip ...") + dist_dir = os.path.join(root, "dist") + if not os.path.isdir(dist_dir): + print(" [失败] 未找到 dist 目录") + return None + index_html = os.path.join(dist_dir, "index.html") + if not os.path.isfile(index_html): + print(" [失败] 未找到 dist/index.html,请先执行 pnpm build") + return None + + zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip") + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for dirpath, _dirs, filenames in os.walk(dist_dir): + for f in filenames: + full = os.path.join(dirpath, f) + arcname = os.path.relpath(full, dist_dir).replace("\\", "/") + zf.write(full, arcname) + print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024)) + return zip_path + except Exception as e: + print(" [失败] 打包异常:", str(e)) + return None + + +# ==================== SSH 上传并解压到 dist2 ==================== + + +def upload_zip_and_extract_to_dist2(cfg, zip_path): + """上传 zip 到服务器并解压到 dist2""" + print("[3/4] SSH 上传 zip 并解压到 dist2 ...") + sys.stdout.flush() + if not cfg.get("password") and not cfg.get("ssh_key"): + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT)) + sys.stdout.flush() + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect( + cfg["host"], + port=DEFAULT_SSH_PORT, + username=cfg["user"], + key_filename=cfg["ssh_key"], + timeout=30, + banner_timeout=30, + ) + else: + client.connect( + cfg["host"], + port=DEFAULT_SSH_PORT, + username=cfg["user"], + password=cfg["password"], + timeout=30, + banner_timeout=30, + ) + print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb) + sys.stdout.flush() + remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip" + sftp = client.open_sftp() + chunk_mb = 5.0 + last_reported = [0] + + def _progress(transferred, total): + if total and total > 0: + now_mb = transferred / (1024 * 1024) + if now_mb - last_reported[0] >= chunk_mb or transferred >= total: + last_reported[0] = now_mb + print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="") + sys.stdout.flush() + + sftp.put(zip_path, remote_zip, callback=_progress) + if zip_size_mb >= chunk_mb: + print("") + print(" [OK] zip 已上传,正在服务器解压到 dist2 ...") + sys.stdout.flush() + sftp.close() + dist2 = cfg["dist2_path"] + cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % ( + dist2, + dist2, + remote_zip, + dist2, + remote_zip, + ) + stdin, stdout, stderr = client.exec_command(cmd, timeout=120) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + if err: + print(" 服务器 stderr: %s" % err[:500]) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 解压失败,退出码: %s" % exit_status) + if out: + print(" stdout: %s" % out[:300]) + return False + print(" [成功] 已解压到: %s" % dist2) + return True + except Exception as e: + print(" [失败] SSH 错误: %s" % str(e)) + import traceback + traceback.print_exc() + return False + finally: + client.close() + + +# ==================== 服务器目录切换:dist → dist1,dist2 → dist ==================== + + +def remote_swap_dist(cfg): + """服务器上:dist→dist1,dist2→dist,删除 dist1,实现无缝切换""" + print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect( + cfg["host"], + port=DEFAULT_SSH_PORT, + username=cfg["user"], + key_filename=cfg["ssh_key"], + timeout=15, + ) + else: + client.connect( + cfg["host"], + port=DEFAULT_SSH_PORT, + username=cfg["user"], + password=cfg["password"], + timeout=15, + ) + base = cfg["base_path"] + # 若当前没有 dist(首次部署),则 dist2 直接改名为 dist;若有 dist 则先备份再替换 + cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base + stdin, stdout, stderr = client.exec_command(cmd, timeout=60) + out = stdout.read().decode("utf-8", errors="replace").strip() + if stdout.channel.recv_exit_status() != 0 or "OK" not in out: + print(" [失败] 切换失败") + return False + print(" [成功] 新版本已切换至: %s" % cfg["dist_path"]) + return True + except Exception as e: + print(" [失败] SSH 错误: %s" % str(e)) + return False + finally: + client.close() + + +# ==================== 主函数 ==================== + + +def main(): + parser = argparse.ArgumentParser( + description="soul-admin 静态站点部署(dist2 解压后目录切换,无缝更新)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist", + ) + parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build") + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + if os.path.isfile(os.path.join(script_dir, "package.json")): + root = script_dir + else: + root = os.path.dirname(script_dir) + + cfg = get_cfg() + print("=" * 60) + print(" soul-admin 部署(dist/dist2 无缝切换)") + print("=" * 60) + print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"])) + print("=" * 60) + + if not args.no_build: + print("[1/4] 本地构建 pnpm build ...") + if not run_build(root): + return 1 + else: + if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile( + os.path.join(root, "dist", "index.html") + ): + print("[错误] 未找到 dist/index.html,请先执行 pnpm build 或去掉 --no-build") + return 1 + print("[1/4] 跳过本地构建") + + zip_path = pack_dist_zip(root) + if not zip_path: + return 1 + if not upload_zip_and_extract_to_dist2(cfg, zip_path): + return 1 + try: + os.remove(zip_path) + except Exception: + pass + if not remote_swap_dist(cfg): + return 1 + print("") + print(" 部署完成!站点目录: %s" % cfg["dist_path"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/soul-admin/devlop.py b/soul-admin/devlop.py new file mode 100644 index 00000000..5219b34d --- /dev/null +++ b/soul-admin/devlop.py @@ -0,0 +1,775 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import os +import sys +import shutil +import tempfile +import argparse +import json +import zipfile +import tarfile +import subprocess +import time +import hashlib + +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +try: + import requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +except ImportError: + print("错误: 请先安装 requests") + print(" pip install requests") + sys.exit(1) + + +# ==================== 配置 ==================== + +# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值(需与 Nginx proxy_pass、ecosystem.config.cjs 一致) +DEPLOY_PM2_APP = "soul" +DEFAULT_DEPLOY_PORT = 3006 +DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul" +DEPLOY_SITE_URL = "https://soul.quwanzhi.com" +# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022) +DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) + +def get_cfg(): + """获取基础部署配置(deploy 模式与 devlop 共用 SSH/宝塔)""" + return { + "host": os.environ.get("DEPLOY_HOST", "43.139.27.93"), + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), + "panel_url": os.environ.get("BAOTA_PANEL_URL", "https://43.139.27.93:9988"), + "api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"), + "pm2_name": os.environ.get("DEPLOY_PM2_APP", DEPLOY_PM2_APP), + "site_url": os.environ.get("DEPLOY_SITE_URL", DEPLOY_SITE_URL), + "port": int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))), + "node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"), + "node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"), + } + + +def get_cfg_devlop(): + """devlop 模式配置:在基础配置上增加 base_path / dist / dist2。 + 实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path, + 否则会从错误目录启动导致 .next/static 等静态资源 404。""" + cfg = get_cfg().copy() + cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", DEPLOY_PROJECT_PATH) + cfg["dist_path"] = cfg["base_path"] + "/dist" + cfg["dist2_path"] = cfg["base_path"] + "/dist2" + return cfg + + +# ==================== 宝塔 API ==================== + +def _get_sign(api_key): + now_time = int(time.time()) + sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest() + request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest() + return now_time, request_token + + +def _baota_request(panel_url, api_key, path, data=None): + req_time, req_token = _get_sign(api_key) + payload = {"request_time": req_time, "request_token": req_token} + if data: + payload.update(data) + url = panel_url.rstrip("/") + "/" + path.lstrip("/") + try: + r = requests.post(url, data=payload, verify=False, timeout=30) + return r.json() if r.text else {} + except Exception as e: + print(" API 请求失败: %s" % str(e)) + return None + + +def get_node_project_list(panel_url, api_key): + for path in ["/project/nodejs/get_project_list", "/plugin?action=a&name=nodejs&s=get_project_list"]: + result = _baota_request(panel_url, api_key, path) + if result and (result.get("status") is True or "data" in result): + return result.get("data", []) + return None + + +def get_node_project_status(panel_url, api_key, pm2_name): + projects = get_node_project_list(panel_url, api_key) + if projects: + for p in projects: + if p.get("name") == pm2_name: + return p + return None + + +def start_node_project(panel_url, api_key, pm2_name): + for path in ["/project/nodejs/start_project", "/plugin?action=a&name=nodejs&s=start_project"]: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 启动成功: %s" % pm2_name) + return True + return False + + +def stop_node_project(panel_url, api_key, pm2_name): + for path in ["/project/nodejs/stop_project", "/plugin?action=a&name=nodejs&s=stop_project"]: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 停止成功: %s" % pm2_name) + return True + return False + + +def restart_node_project(panel_url, api_key, pm2_name): + project_status = get_node_project_status(panel_url, api_key, pm2_name) + if project_status: + print(" 项目状态: %s" % project_status.get("status", "未知")) + for path in ["/project/nodejs/restart_project", "/plugin?action=a&name=nodejs&s=restart_project"]: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 重启成功: %s" % pm2_name) + return True + if result and "msg" in result: + print(" API 返回: %s" % result.get("msg")) + print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确") + return False + + +def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=None, node_path=None): + if port is None: + port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))) + port_env = "PORT=%d " % port + run_cmd = port_env + ("%s/node server.js" % node_path if node_path else "node server.js") + payload = {"name": pm2_name, "path": project_path, "run_cmd": run_cmd, "port": str(port)} + for path in ["/project/nodejs/add_project", "/plugin?action=a&name=nodejs&s=add_project"]: + result = _baota_request(panel_url, api_key, path, payload) + if result and result.get("status") is True: + print(" [成功] 项目配置已更新: %s" % pm2_name) + return True + if result and "msg" in result: + print(" API 返回: %s" % result.get("msg")) + return False + + +# ==================== 本地构建 ==================== + +def run_build(root): + """执行本地 pnpm build""" + use_shell = sys.platform == "win32" + standalone = os.path.join(root, ".next", "standalone") + server_js = os.path.join(standalone, "server.js") + + try: + r = subprocess.run( + ["pnpm", "build"], + cwd=root, + shell=use_shell, + timeout=600, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + stdout_text = r.stdout or "" + stderr_text = r.stderr or "" + combined = stdout_text + stderr_text + is_windows_symlink_error = ( + sys.platform == "win32" + and r.returncode != 0 + and ("EPERM" in combined or "symlink" in combined.lower() or "operation not permitted" in combined.lower() or "errno: -4048" in combined) + ) + + if r.returncode != 0: + if is_windows_symlink_error: + print(" [警告] Windows 符号链接权限错误(EPERM)") + print(" 解决方案:开启开发者模式 / 以管理员运行 / 或使用 --no-build") + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [成功] standalone 输出可用,继续部署") + return True + return False + print(" [失败] 构建失败,退出码:", r.returncode) + for line in (stdout_text.strip().split("\n") or [])[-10:]: + print(" " + line) + return False + except subprocess.TimeoutExpired: + print(" [失败] 构建超时(超过10分钟)") + return False + except FileNotFoundError: + print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm") + return False + except Exception as e: + print(" [失败] 构建异常:", str(e)) + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [提示] 可尝试使用 --no-build 跳过构建") + return False + + if not os.path.isdir(standalone) or not os.path.isfile(server_js): + print(" [失败] 未找到 .next/standalone 或 server.js") + return False + print(" [成功] 构建完成") + return True + + +def clean_standalone_before_build(root, retries=3, delay=2): + """构建前删除 .next/standalone,避免 Windows EBUSY""" + standalone = os.path.join(root, ".next", "standalone") + if not os.path.isdir(standalone): + return True + for attempt in range(1, retries + 1): + try: + shutil.rmtree(standalone) + print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt) + return True + except (OSError, PermissionError): + if attempt < retries: + print(" [清理] 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries)) + time.sleep(delay) + else: + print(" [失败] 无法删除 .next/standalone,可改用 --no-build") + return False + return False + + +# ==================== 打包(deploy 模式:tar.gz) ==================== + +def _copy_with_dereference(src, dst): + if os.path.islink(src): + link_target = os.readlink(src) + real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target) + if os.path.exists(real_path): + if os.path.isdir(real_path): + shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(real_path, dst) + else: + shutil.copy2(src, dst, follow_symlinks=False) + elif os.path.isdir(src): + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + + +def pack_standalone_tar(root): + """打包 standalone 为 tar.gz(deploy 模式用)""" + print("[2/4] 打包 standalone ...") + standalone = os.path.join(root, ".next", "standalone") + static_src = os.path.join(root, ".next", "static") + public_src = os.path.join(root, "public") + ecosystem_src = os.path.join(root, "ecosystem.config.cjs") + + if not os.path.isdir(standalone) or not os.path.isdir(static_src): + print(" [失败] 未找到 .next/standalone 或 .next/static") + return None + chunks_dir = os.path.join(static_src, "chunks") + if not os.path.isdir(chunks_dir): + print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)") + return None + + staging = tempfile.mkdtemp(prefix="soul_deploy_") + try: + for name in os.listdir(standalone): + _copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name)) + node_modules_dst = os.path.join(staging, "node_modules") + pnpm_dir = os.path.join(node_modules_dst, ".pnpm") + if os.path.isdir(pnpm_dir): + for dep in ["styled-jsx"]: + dep_in_root = os.path.join(node_modules_dst, dep) + if not os.path.exists(dep_in_root): + for pnpm_pkg in os.listdir(pnpm_dir): + if pnpm_pkg.startswith(dep + "@"): + src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) + if os.path.isdir(src_dep): + shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) + break + static_dst = os.path.join(staging, ".next", "static") + if os.path.exists(static_dst): + shutil.rmtree(static_dst) + os.makedirs(os.path.dirname(static_dst), exist_ok=True) + shutil.copytree(static_src, static_dst) + # 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404 + next_root = os.path.join(root, ".next") + next_staging = os.path.join(staging, ".next") + index_files = [ + "BUILD_ID", + "build-manifest.json", + "app-path-routes-manifest.json", + "routes-manifest.json", + "prerender-manifest.json", + "required-server-files.json", + "fallback-build-manifest.json", + ] + for name in index_files: + src = os.path.join(next_root, name) + if os.path.isfile(src): + shutil.copy2(src, os.path.join(next_staging, name)) + print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等") + if os.path.isdir(public_src): + shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True) + if os.path.isfile(ecosystem_src): + shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) + pkg_json = os.path.join(staging, "package.json") + if os.path.isfile(pkg_json): + try: + with open(pkg_json, "r", encoding="utf-8") as f: + data = json.load(f) + data.setdefault("scripts", {})["start"] = "node server.js" + with open(pkg_json, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception: + pass + tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for name in os.listdir(staging): + tf.add(os.path.join(staging, name), arcname=name) + print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024)) + return tarball + except Exception as e: + print(" [失败] 打包异常:", str(e)) + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== Node 环境检查 & SSH 上传(deploy 模式) ==================== + +def check_node_environments(cfg): + print("[检查] Node 环境 ...") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if cfg.get("ssh_key"): + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15) + else: + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15) + stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10) + print(" 默认 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "未找到")) + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5) + print(" 配置 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "不可用")) + return True + except Exception as e: + print(" [警告] %s" % str(e)) + return False + finally: + client.close() + + +def upload_and_extract(cfg, tarball_path): + """SSH 上传 tar.gz 并解压到 project_path(deploy 模式)""" + print("[3/4] SSH 上传并解压 ...") + if not cfg.get("password") and not cfg.get("ssh_key"): + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15) + else: + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15) + sftp = client.open_sftp() + remote_tar = "/tmp/soul_deploy.tar.gz" + remote_script = "/tmp/soul_deploy_extract.sh" + sftp.put(tarball_path, remote_tar) + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + project_path = cfg["project_path"] + script_content = """#!/bin/bash +export PATH=%s:$PATH +cd %s +rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null +tar -xzf %s +rm -f %s +echo OK +""" % (node_path, project_path, remote_tar, remote_tar) + with sftp.open(remote_script, "w") as f: + f.write(script_content) + sftp.close() + client.exec_command("chmod +x %s" % remote_script, timeout=10) + stdin, stdout, stderr = client.exec_command("bash %s" % remote_script, timeout=120) + out = stdout.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 解压失败,退出码:", exit_status) + return False + print(" [成功] 解压完成: %s" % project_path) + return True + except Exception as e: + print(" [失败] SSH 错误:", str(e)) + return False + finally: + client.close() + + +def deploy_via_baota_api(cfg): + """宝塔 API 重启 Node 项目(deploy 模式)""" + print("[4/4] 宝塔 API 管理 Node 项目 ...") + panel_url, api_key, pm2_name = cfg["panel_url"], cfg["api_key"], cfg["pm2_name"] + project_path = cfg["project_path"] + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + port = cfg["port"] + + if not get_node_project_status(panel_url, api_key, pm2_name): + add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path) + stop_node_project(panel_url, api_key, pm2_name) + time.sleep(2) + ok = restart_node_project(panel_url, api_key, pm2_name) + if not ok: + ok = start_node_project(panel_url, api_key, pm2_name) + if not ok: + print(" 请到宝塔 Node 项目手动重启 %s,路径: %s" % (pm2_name, project_path)) + return ok + + +# ==================== 打包(devlop 模式:zip) ==================== + +ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"} +ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"} +ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map") + + +def _should_exclude_from_zip(arcname, is_file=True): + parts = arcname.replace("\\", "/").split("/") + for part in parts: + if part in ZIP_EXCLUDE_DIRS: + return True + if is_file and parts: + name = parts[-1] + if name in ZIP_EXCLUDE_FILE_NAMES or any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES): + return True + return False + + +def pack_standalone_zip(root): + """打包 standalone 为 zip(devlop 模式用)""" + print("[2/7] 打包 standalone 为 zip ...") + standalone = os.path.join(root, ".next", "standalone") + static_src = os.path.join(root, ".next", "static") + public_src = os.path.join(root, "public") + ecosystem_src = os.path.join(root, "ecosystem.config.cjs") + + if not os.path.isdir(standalone) or not os.path.isdir(static_src): + print(" [失败] 未找到 .next/standalone 或 .next/static") + return None + chunks_dir = os.path.join(static_src, "chunks") + if not os.path.isdir(chunks_dir): + print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)") + return None + + staging = tempfile.mkdtemp(prefix="soul_devlop_") + try: + for name in os.listdir(standalone): + _copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name)) + node_modules_dst = os.path.join(staging, "node_modules") + pnpm_dir = os.path.join(node_modules_dst, ".pnpm") + if os.path.isdir(pnpm_dir): + for dep in ["styled-jsx"]: + dep_in_root = os.path.join(node_modules_dst, dep) + if not os.path.exists(dep_in_root): + for pnpm_pkg in os.listdir(pnpm_dir): + if pnpm_pkg.startswith(dep + "@"): + src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) + if os.path.isdir(src_dep): + shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) + break + os.makedirs(os.path.join(staging, ".next"), exist_ok=True) + shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True) + # 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404 + next_root = os.path.join(root, ".next") + next_staging = os.path.join(staging, ".next") + index_files = [ + "BUILD_ID", + "build-manifest.json", + "app-path-routes-manifest.json", + "routes-manifest.json", + "prerender-manifest.json", + "required-server-files.json", + "fallback-build-manifest.json", + ] + for name in index_files: + src = os.path.join(next_root, name) + if os.path.isfile(src): + shutil.copy2(src, os.path.join(next_staging, name)) + print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等") + if os.path.isdir(public_src): + shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True) + if os.path.isfile(ecosystem_src): + shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) + pkg_json = os.path.join(staging, "package.json") + if os.path.isfile(pkg_json): + try: + with open(pkg_json, "r", encoding="utf-8") as f: + data = json.load(f) + data.setdefault("scripts", {})["start"] = "node server.js" + with open(pkg_json, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception: + pass + server_js = os.path.join(staging, "server.js") + if os.path.isfile(server_js): + try: + deploy_port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))) + with open(server_js, "r", encoding="utf-8") as f: + c = f.read() + if "|| 3000" in c: + with open(server_js, "w", encoding="utf-8") as f: + f.write(c.replace("|| 3000", "|| %d" % deploy_port)) + except Exception: + pass + zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for name in os.listdir(staging): + path = os.path.join(staging, name) + if os.path.isfile(path): + if not _should_exclude_from_zip(name): + zf.write(path, name) + else: + for dirpath, dirs, filenames in os.walk(path): + dirs[:] = [d for d in dirs if not _should_exclude_from_zip(os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False)] + for f in filenames: + full = os.path.join(dirpath, f) + arcname = os.path.join(name, os.path.relpath(full, path)) + if not _should_exclude_from_zip(arcname): + zf.write(full, arcname) + print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024)) + return zip_path + except Exception as e: + print(" [失败] 打包异常:", str(e)) + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def upload_zip_and_extract_to_dist2(cfg, zip_path): + """上传 zip 并解压到 dist2(devlop 模式)""" + print("[3/7] SSH 上传 zip 并解压到 dist2 ...") + sys.stdout.flush() + if not cfg.get("password") and not cfg.get("ssh_key"): + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT)) + sys.stdout.flush() + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30, banner_timeout=30) + else: + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30, banner_timeout=30) + print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb) + sys.stdout.flush() + remote_zip = cfg["base_path"].rstrip("/") + "/soul_devlop.zip" + sftp = client.open_sftp() + # 上传进度:每 5MB 打印一次 + chunk_mb = 5.0 + last_reported = [0] + + def _progress(transferred, total): + if total and total > 0: + now_mb = transferred / (1024 * 1024) + if now_mb - last_reported[0] >= chunk_mb or transferred >= total: + last_reported[0] = now_mb + print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="") + sys.stdout.flush() + + sftp.put(zip_path, remote_zip, callback=_progress) + if zip_size_mb >= chunk_mb: + print("") + print(" [OK] zip 已上传,正在服务器解压(约 1–3 分钟)...") + sys.stdout.flush() + sftp.close() + dist2 = cfg["dist2_path"] + cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (dist2, dist2, remote_zip, dist2, remote_zip) + stdin, stdout, stderr = client.exec_command(cmd, timeout=300) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + if err: + print(" 服务器 stderr: %s" % err[:500]) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 解压失败,退出码: %s" % exit_status) + if out: + print(" stdout: %s" % out[:300]) + return False + print(" [成功] 已解压到: %s" % dist2) + return True + except Exception as e: + print(" [失败] SSH 错误: %s" % str(e)) + import traceback + traceback.print_exc() + return False + finally: + client.close() + + +def run_pnpm_install_in_dist2(cfg): + """服务器 dist2 内执行 pnpm install,阻塞等待完成后再返回(改目录前必须完成)""" + print("[4/7] 服务器 dist2 内执行 pnpm install(等待完成后再切换目录)...") + sys.stdout.flush() + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15) + else: + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15) + stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10) + pnpm_path = stdout.read().decode("utf-8", errors="replace").strip() + if not pnpm_path: + return False, "未找到 pnpm,请服务器安装: npm install -g pnpm" + cmd = "bash -lc 'cd %s && %s install'" % (cfg["dist2_path"], pnpm_path) + stdin, stdout, stderr = client.exec_command(cmd, timeout=300) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + if stdout.channel.recv_exit_status() != 0: + return False, "pnpm install 失败\n" + (err or out) + print(" [成功] dist2 内 pnpm install 已执行完成,可安全切换目录") + return True, None + except Exception as e: + return False, str(e) + finally: + client.close() + + +def remote_swap_dist_and_restart(cfg): + """暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)""" + print("[5/7] 宝塔 API 暂停 Node 项目 ...") + stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]) + time.sleep(2) + print("[6/7] 服务器切换目录: dist→dist1, dist2→dist ...") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15) + else: + client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15) + cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % cfg["base_path"] + stdin, stdout, stderr = client.exec_command(cmd, timeout=60) + out = stdout.read().decode("utf-8", errors="replace").strip() + if stdout.channel.recv_exit_status() != 0 or "OK" not in out: + print(" [失败] 切换失败") + return False + print(" [成功] 新版本位于 %s" % cfg["dist_path"]) + finally: + client.close() + # 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404 + print("[7/7] 更新宝塔 Node 项目路径并重启 ...") + add_or_update_node_project( + cfg["panel_url"], cfg["api_key"], cfg["pm2_name"], + cfg["dist_path"], # 使用 dist_path,不是 project_path + port=cfg["port"], + node_path=cfg.get("node_path"), + ) + if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]): + print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"])) + return False + return True + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description="Soul 创业派对 - 统一部署脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__) + parser.add_argument("--mode", choices=["devlop", "deploy"], default="devlop", help="devlop=dist切换(默认), deploy=直接覆盖") + parser.add_argument("--no-build", action="store_true", help="跳过本地构建") + parser.add_argument("--no-upload", action="store_true", help="仅 deploy 模式:跳过 SSH 上传") + parser.add_argument("--no-api", action="store_true", help="仅 deploy 模式:上传后不调宝塔 API") + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + # 支持 devlop.py 在项目根或 scripts/ 下:以含 package.json 的目录为 root + if os.path.isfile(os.path.join(script_dir, "package.json")): + root = script_dir + else: + root = os.path.dirname(script_dir) + + if args.mode == "devlop": + cfg = get_cfg_devlop() + print("=" * 60) + print(" Soul 自动部署(dist 切换)") + print("=" * 60) + print(" 服务器: %s@%s 目录: %s Node: %s" % (cfg["user"], cfg["host"], cfg["base_path"], cfg["pm2_name"])) + print("=" * 60) + if not args.no_build: + print("[1/7] 本地构建 pnpm build ...") + if sys.platform == "win32" and not clean_standalone_before_build(root): + return 1 + if not run_build(root): + return 1 + elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")): + print("[错误] 未找到 .next/standalone/server.js") + return 1 + else: + print("[1/7] 跳过本地构建") + zip_path = pack_standalone_zip(root) + if not zip_path: + return 1 + if not upload_zip_and_extract_to_dist2(cfg, zip_path): + return 1 + try: + os.remove(zip_path) + except Exception: + pass + # 必须在 dist2 内 pnpm install 执行完成后再切换目录 + ok, err = run_pnpm_install_in_dist2(cfg) + if not ok: + print(" [失败] %s" % (err or "pnpm install 失败")) + return 1 + # install 已完成,再执行 dist→dist1、dist2→dist 切换 + if not remote_swap_dist_and_restart(cfg): + return 1 + print("") + print(" 部署完成!运行目录: %s" % cfg["dist_path"]) + return 0 + + # deploy 模式 + cfg = get_cfg() + print("=" * 60) + print(" Soul 一键部署(直接覆盖)") + print("=" * 60) + print(" 服务器: %s@%s 项目路径: %s PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"])) + print("=" * 60) + if not args.no_upload: + check_node_environments(cfg) + if not args.no_build: + print("[1/4] 本地构建 ...") + if not run_build(root): + return 1 + elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")): + print("[错误] 未找到 .next/standalone/server.js") + return 1 + else: + print("[1/4] 跳过本地构建") + tarball = pack_standalone_tar(root) + if not tarball: + return 1 + if not args.no_upload: + if not upload_and_extract(cfg, tarball): + return 1 + try: + os.remove(tarball) + except Exception: + pass + else: + print(" 压缩包: %s" % tarball) + if not args.no_api and not args.no_upload: + deploy_via_baota_api(cfg) + print("") + print(" 部署完成!站点: %s" % cfg["site_url"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/soul-admin/src/api/client.ts b/soul-admin/src/api/client.ts index 26bdeea7..46becbaa 100644 --- a/soul-admin/src/api/client.ts +++ b/soul-admin/src/api/client.ts @@ -4,10 +4,13 @@ * 无缝切换:仅修改 VITE_API_BASE_URL 即可切换后端 */ +/** 未设置环境变量时使用的默认 API 地址(零配置部署) */ +const DEFAULT_API_BASE = 'https://soulapi.quwanzhi.com' + const getBaseUrl = (): string => { const url = import.meta.env.VITE_API_BASE_URL if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '') - return '' + return DEFAULT_API_BASE } /** 请求完整 URL:baseUrl + path,path 必须与现网一致(如 /api/orders) */ diff --git a/soul-admin/src/pages/match/MatchPage.tsx b/soul-admin/src/pages/match/MatchPage.tsx index d33309e4..c9997142 100644 --- a/soul-admin/src/pages/match/MatchPage.tsx +++ b/soul-admin/src/pages/match/MatchPage.tsx @@ -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 }) diff --git a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx index 15c7d2ab..61dd68c2 100644 --- a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx +++ b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx @@ -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) { diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index b0851e81..5f620e03 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -157,7 +157,7 @@ export function SettingsPage() { const load = async () => { try { const [configRes, appConfigRes] = await Promise.all([ - get<{ success?: boolean; data?: unknown } | Record>('/api/db/config'), + get<{ success?: boolean; data?: unknown } | Record>('/api/db/config/full'), get>('/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) diff --git a/soul-api/.env b/soul-api/.env index 092f8207..230286a5 100644 --- a/soul-api/.env +++ b/soul-api/.env @@ -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 + + diff --git a/soul-api/.env.example b/soul-api/.env.example index 25561bde..bdd9a11c 100644 --- a/soul-api/.env.example +++ b/soul-api/.env.example @@ -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 diff --git a/soul-api/__pycache__/devlop.cpython-311.pyc b/soul-api/__pycache__/devlop.cpython-311.pyc new file mode 100644 index 00000000..f515d814 Binary files /dev/null and b/soul-api/__pycache__/devlop.cpython-311.pyc differ diff --git a/soul-api/certs/apiclient_cert.pem b/soul-api/certs/apiclient_cert.pem new file mode 100644 index 00000000..ef4a885a --- /dev/null +++ b/soul-api/certs/apiclient_cert.pem @@ -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----- diff --git a/soul-api/certs/apiclient_key.pem b/soul-api/certs/apiclient_key.pem new file mode 100644 index 00000000..ac63c73d --- /dev/null +++ b/soul-api/certs/apiclient_key.pem @@ -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----- diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go index 1b22c815..257ddb36 100644 --- a/soul-api/cmd/server/main.go +++ b/soul-api/cmd/server/main.go @@ -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{ diff --git a/soul-api/devlop.py b/soul-api/devlop.py index ddac3449..02bc7c9a 100644 --- a/soul-api/devlop.py +++ b/soul-api/devlop.py @@ -1,14 +1,21 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -soul-api Go 项目一键部署到宝塔 +soulApp (soul-api) Go 项目一键部署到宝塔 - 本地交叉编译 Linux 二进制 - 上传到 /www/wwwroot/自营/soul-api -- 重启服务(nohup 或跳过) +- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动 + +宝塔 API 重启(可选):在环境变量或 .env 中设置 + BT_PANEL_URL = https://你的面板地址:9988 + BT_API_KEY = 面板 设置 -> API 接口 中的密钥 + BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致) +并安装 requests: pip install requests """ from __future__ import print_function +import hashlib import os import sys import tempfile @@ -16,6 +23,7 @@ import argparse import subprocess import shutil import tarfile +import time try: import paramiko @@ -24,19 +32,40 @@ except ImportError: print(" pip install paramiko") sys.exit(1) +try: + import requests + try: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except Exception: + pass +except ImportError: + requests = None + # ==================== 配置 ==================== DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul-api" DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) +# 宝塔 API 密钥(写死,用于部署后重启 Go 项目) +BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT" + + def get_cfg(): + host = os.environ.get("DEPLOY_HOST", "43.139.27.93") + bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/") + if not bt_url: + bt_url = "https://%s:9988" % host return { - "host": os.environ.get("DEPLOY_HOST", "43.139.27.93"), + "host": host, "user": os.environ.get("DEPLOY_USER", "root"), "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), + "bt_panel_url": bt_url, + "bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT), + "bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"), } @@ -50,12 +79,14 @@ def run_build(root): env["GOOS"] = "linux" env["GOARCH"] = "amd64" env["CGO_ENABLED"] = "0" + # 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path" + cmd = ["go", "build", "-o", "soul-api", "./cmd/server"] try: r = subprocess.run( - ["go", "build", "-o", "soul-api", "./cmd/server"], + cmd, cwd=root, env=env, - shell=(sys.platform == "win32"), + shell=False, timeout=120, capture_output=True, text=True, @@ -116,10 +147,64 @@ def pack_deploy(root, binary_path, include_env=True): shutil.rmtree(staging, ignore_errors=True) +# ==================== 宝塔 API 重启 ==================== + + +def restart_via_bt_api(cfg): + """通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)""" + url = cfg.get("bt_panel_url") or "" + key = cfg.get("bt_api_key") or "" + name = cfg.get("bt_go_project_name", "soulApi") + if not url or not key: + return False + if not requests: + print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。pip install requests") + return False + try: + req_time = int(time.time()) + sk_md5 = hashlib.md5(key.encode()).hexdigest() + req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() + # 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准 + base = url.rstrip("/") + params = {"request_time": req_time, "request_token": req_token} + # 常见形式:/plugin?name=go_project,POST 带 action、project_name + for action in ("stop_go_project", "start_go_project"): + data = dict(params) + data["action"] = action + data["project_name"] = name + r = requests.post( + base + "/plugin?name=go_project", + data=data, + timeout=15, + verify=False, + ) + if r.status_code != 200: + continue + j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} + if action == "stop_go_project": + time.sleep(2) + if j.get("status") is False and j.get("msg"): + print(" [宝塔API] %s: %s" % (action, j.get("msg", ""))) + # 再调一次 start 确保启动 + data = dict(params) + data["action"] = "start_go_project" + data["project_name"] = name + r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False) + if r.status_code == 200: + j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} + if j.get("status") is True: + print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name) + return True + return False + except Exception as e: + print(" [宝塔API 失败] %s" % str(e)) + return False + + # ==================== SSH 上传 ==================== -def upload_and_extract(cfg, tarball_path, no_restart=False): +def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"): """上传 tar.gz 到服务器并解压、重启""" print("[3/4] SSH 上传并解压 ...") if not cfg.get("password") and not cfg.get("ssh_key"): @@ -159,18 +244,27 @@ def upload_and_extract(cfg, tarball_path, no_restart=False): print(" [成功] 已解压到: %s" % project_path) if not no_restart: - print("[4/4] 重启 soul-api 服务 ...") - restart_cmd = ( - "cd %s && pkill -f 'soul-api' 2>/dev/null; sleep 2; " - "nohup ./soul-api >> soul-api.log 2>&1 & sleep 1; " - "pgrep -f soul-api >/dev/null && echo RESTART_OK || echo RESTART_FAIL" - ) % project_path - stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=15) - out = stdout.read().decode("utf-8", errors="replace").strip() - if "RESTART_OK" in out: - print(" [成功] soul-api 已重启") - else: - print(" [警告] 重启状态未知,请手动检查: cd %s && ./soul-api" % project_path) + print("[4/4] 重启 soulApp 服务 ...") + ok = False + if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")): + ok = restart_via_bt_api(cfg) + if not ok and restart_method in ("auto", "ssh"): + # SSH:用 setsid nohup 避免断开杀进程,多等几秒再检测 + restart_cmd = ( + "cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; " + "setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null && 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: diff --git a/soul-api/go.mod b/soul-api/go.mod index cbc07e23..1ec3255f 100644 --- a/soul-api/go.mod +++ b/soul-api/go.mod @@ -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 ) diff --git a/soul-api/go.sum b/soul-api/go.sum index 446ace80..d8353fbd 100644 --- a/soul-api/go.sum +++ b/soul-api/go.sum @@ -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= diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go index e62cdcab..115cffdf 100644 --- a/soul-api/internal/config/config.go +++ b/soul-api/internal/config/config.go @@ -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 } diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go index 8bab20e0..ff60f596 100644 --- a/soul-api/internal/handler/book.go +++ b/soul-api/internal/handler/book.go @@ -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 diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go index a5f1258f..72b2f261 100644 --- a/soul-api/internal/handler/ckb.go +++ b/soul-api/internal/handler/ckb.go @@ -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 diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index 49836bf2..7cdc16f3 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -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() diff --git a/soul-api/internal/handler/match.go b/soul-api/internal/handler/match.go index 29eefdf7..0b2d77fd 100644 --- a/soul-api/internal/handler/match.go +++ b/soul-api/internal/handler/match.go @@ -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), + }) } diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go index 868364c1..c2bdfe83 100644 --- a/soul-api/internal/handler/miniprogram.go +++ b/soul-api/internal/handler/miniprogram.go @@ -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 `` +} + +func failResponse() string { + return `` } // 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() } diff --git a/soul-api/internal/handler/payment.go b/soul-api/internal/handler/payment.go index dbe71553..e04127ef 100644 --- a/soul-api/internal/handler/payment.go +++ b/soul-api/internal/handler/payment.go @@ -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"}) } diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go index 22aab14b..216b636e 100644 --- a/soul-api/internal/handler/referral.go +++ b/soul-api/internal/handler/referral.go @@ -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 } diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go index eda35d47..b8ff1fa7 100644 --- a/soul-api/internal/handler/upload.go +++ b/soul-api/internal/handler/upload.go @@ -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": "删除成功"}) } diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go index 3ca2aa8b..50583e64 100644 --- a/soul-api/internal/handler/user.go +++ b/soul-api/internal/handler/user.go @@ -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": "更新成功"}) } diff --git a/soul-api/internal/handler/wechat.go b/soul-api/internal/handler/wechat.go index 86f83816..555fa007 100644 --- a/soul-api/internal/handler/wechat.go +++ b/soul-api/internal/handler/wechat.go @@ -1,7 +1,14 @@ package handler import ( + "fmt" "net/http" + "strings" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" "github.com/gin-gonic/gin" ) @@ -10,3 +17,144 @@ import ( func WechatLogin(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } + +// WechatPhoneLoginReq 手机号登录请求:code 为 wx.login() 的 code,phoneCode 为 getPhoneNumber 返回的 code +type WechatPhoneLoginReq struct { + Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId + PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号 +} + +// WechatPhoneLogin POST /api/wechat/phone-login +// 请求体:code(必填)+ phoneCode(必填)。先 code2session 得到 openId,再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。 +func WechatPhoneLogin(c *gin.Context) { + var req WechatPhoneLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"}) + return + } + if req.Code == "" || req.PhoneCode == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"}) + return + } + + openID, sessionKey, _, err := wechat.Code2Session(req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)}) + return + } + phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)}) + return + } + + db := database.DB() + var user model.User + result := db.Where("open_id = ?", openID).First(&user) + isNewUser := result.Error != nil + + if isNewUser { + referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:]) + nickname := "微信用户" + openID[len(openID)-4:] + avatar := "" + hasFullBook := false + earnings := 0.0 + pendingEarnings := 0.0 + referralCount := 0 + purchasedSections := "[]" + phone := phoneNumber + if countryCode != "" && countryCode != "86" { + phone = "+" + countryCode + " " + phoneNumber + } + user = model.User{ + ID: openID, + OpenID: &openID, + SessionKey: &sessionKey, + Nickname: &nickname, + Avatar: &avatar, + Phone: &phone, + ReferralCode: &referralCode, + HasFullBook: &hasFullBook, + PurchasedSections: &purchasedSections, + Earnings: &earnings, + PendingEarnings: &pendingEarnings, + ReferralCount: &referralCount, + } + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"}) + return + } + } else { + phone := phoneNumber + if countryCode != "" && countryCode != "86" { + phone = "+" + countryCode + " " + phoneNumber + } + db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone}) + user.Phone = &phone + } + + var orderRows []struct { + ProductID string `gorm:"column:product_id"` + } + db.Raw(` + SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section' + `, user.ID).Scan(&orderRows) + purchasedSections := []string{} + for _, row := range orderRows { + if row.ProductID != "" { + purchasedSections = append(purchasedSections, row.ProductID) + } + } + + responseUser := map[string]interface{}{ + "id": user.ID, + "openId": strVal(user.OpenID), + "nickname": strVal(user.Nickname), + "avatar": strVal(user.Avatar), + "phone": strVal(user.Phone), + "wechatId": strVal(user.WechatID), + "referralCode": strVal(user.ReferralCode), + "hasFullBook": boolVal(user.HasFullBook), + "purchasedSections": purchasedSections, + "earnings": floatVal(user.Earnings), + "pendingEarnings": floatVal(user.PendingEarnings), + "referralCount": intVal(user.ReferralCount), + "createdAt": user.CreatedAt, + } + token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix()) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "openId": openID, + "user": responseUser, + "token": token, + }, + "isNewUser": isNewUser, + }) +} + +func strVal(p *string) string { + if p == nil { + return "" + } + return *p +} +func boolVal(p *bool) bool { + if p == nil { + return false + } + return *p +} +func floatVal(p *float64) float64 { + if p == nil { + return 0 + } + return *p +} +func intVal(p *int) int { + if p == nil { + return 0 + } + return *p +} diff --git a/soul-api/internal/handler/withdraw.go b/soul-api/internal/handler/withdraw.go index 08850270..38b62fa4 100644 --- a/soul-api/internal/handler/withdraw.go +++ b/soul-api/internal/handler/withdraw.go @@ -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, + }, + }) } diff --git a/soul-api/internal/model/reading_progress.go b/soul-api/internal/model/reading_progress.go new file mode 100644 index 00000000..a9a93c3f --- /dev/null +++ b/soul-api/internal/model/reading_progress.go @@ -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" } diff --git a/soul-api/internal/model/referral_binding.go b/soul-api/internal/model/referral_binding.go index 091530b5..cfd2b53f 100644 --- a/soul-api/internal/model/referral_binding.go +++ b/soul-api/internal/model/referral_binding.go @@ -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" } diff --git a/soul-api/internal/model/referral_visit.go b/soul-api/internal/model/referral_visit.go index de5a3863..8b6918e1 100644 --- a/soul-api/internal/model/referral_visit.go +++ b/soul-api/internal/model/referral_visit.go @@ -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" } diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go index 300bc584..fdf11de2 100644 --- a/soul-api/internal/model/user.go +++ b/soul-api/internal/model/user.go @@ -2,25 +2,26 @@ package model import "time" - // User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰) type User struct { ID string `gorm:"column:id;primaryKey;size:50" json:"id"` OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"` + SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"` Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"` Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"` WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"` ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"` HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"` - Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"` - PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"` - ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"` + PurchasedSections *string `gorm:"column:purchased_sections;type:json" json:"-"` // 内部字段,实际数据从 orders 表查 + Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"` + PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"` + ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` - IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"` - WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"` - Source *string `gorm:"column:source;size:50" json:"source,omitempty"` + IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"` + WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"` + Source *string `gorm:"column:source;size:50" json:"source,omitempty"` } func (User) TableName() string { return "users" } diff --git a/soul-api/internal/model/user_address.go b/soul-api/internal/model/user_address.go new file mode 100644 index 00000000..3c5b4818 --- /dev/null +++ b/soul-api/internal/model/user_address.go @@ -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" } diff --git a/soul-api/internal/model/withdrawal.go b/soul-api/internal/model/withdrawal.go index f0dc56ec..59aca7fb 100644 --- a/soul-api/internal/model/withdrawal.go +++ b/soul-api/internal/model/withdrawal.go @@ -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" } diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index a52fade8..1adb4ef3 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -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 diff --git a/soul-api/internal/wechat/miniprogram.go b/soul-api/internal/wechat/miniprogram.go new file mode 100644 index 00000000..d27c13b2 --- /dev/null +++ b/soul-api/internal/wechat/miniprogram.go @@ -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 := "" + for k, v := range data { + xml += fmt.Sprintf("<%s>", k, v, k) + } + xml += "" + return xml +} + +func xmlToMap(xmlStr string) map[string]string { + result := make(map[string]string) + + // 简单的 XML 解析(仅支持 value 格式) + 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] == "' { + 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) +} diff --git a/soul-api/internal/wechat/transfer.go b/soul-api/internal/wechat/transfer.go new file mode 100644 index 00000000..4d0fc06d --- /dev/null +++ b/soul-api/internal/wechat/transfer.go @@ -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("转账回调需要完整实现") +} diff --git a/soul-api/server.exe b/soul-api/server.exe index fd8edd9c..61dc23ae 100644 Binary files a/soul-api/server.exe and b/soul-api/server.exe differ diff --git a/soul-api/soul-api b/soul-api/soul-api index cbb6e66b..a32cbbed 100644 Binary files a/soul-api/soul-api and b/soul-api/soul-api differ diff --git a/soul-api/soul-api.exe b/soul-api/soul-api.exe new file mode 100644 index 00000000..0e61ecb6 Binary files /dev/null and b/soul-api/soul-api.exe differ diff --git a/soul-api/tmp/main.exe b/soul-api/tmp/main.exe new file mode 100644 index 00000000..61dc23ae Binary files /dev/null and b/soul-api/tmp/main.exe differ diff --git a/soul-api/wechat/info.log b/soul-api/wechat/info.log new file mode 100644 index 00000000..48e941af --- /dev/null +++ b/soul-api/wechat/info.log @@ -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\"}"} diff --git a/soul-api/宝塔反向代理说明.txt b/soul-api/宝塔反向代理说明.txt new file mode 100644 index 00000000..a776fa15 --- /dev/null +++ b/soul-api/宝塔反向代理说明.txt @@ -0,0 +1,76 @@ +# soul-api 域名 404 原因与解决 + +## 原因 +域名请求先到 Nginx,若没有把请求转发到本机 8080 的 Go,或站点用了 root/静态目录,就会 404。 + +--- + +## 一、先确认 Go 是否在跑(必做) + +在宝塔终端或 SSH 里执行: + + curl -s http://127.0.0.1:8080/health + +- 若返回 {"status":"ok"}:说明 Go 正常,问题在 Nginx,看下面第二步。 +- 若连接被拒绝或超时:说明 8080 没在监听。去 宝塔 → Go项目管理 → soulApi → 服务状态,看是否“运行中”;看“项目日志”是否有报错。 + +--- + +## 二、Nginx 必须“整站走代理”,不能走 root + +添加了反向代理仍 404,多半是: + +- 站点默认有 location / { root ...; index ...; },请求被当成静态文件处理,/health 找不到就 404; +- 或反向代理只绑在了子路径(如 /api),/ 和 /health 没被代理。 + +做法:让 soulapi.quwanzhi.com 的**所有路径**都走 8080,不要用 root。 + +在宝塔:网站 → soulapi.quwanzhi.com → 设置 → 配置文件,找到该站点的 server { ... },按下面两种方式之一改。 + +### 方式 A:只保留一个 location /(推荐) + +把 server 里**原来的** location / { ... }(含 root、index 的那段)**删掉或注释掉**,只保留下面这一段: + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +保存 → 重载 Nginx(或 宝塔 里点“重载配置”)。 + +### 方式 B:整站用下面这一整段 server(HTTPS 示例) + +若你希望整站只做反向代理、不混静态,可以把该站点的 server 块整体替换成下面内容(把 your_ssl_cert 等换成你实际的证书路径;没有 SSL 就只用 listen 80 那段): + + server { + listen 80; + listen 443 ssl http2; + server_name soulapi.quwanzhi.com; + # SSL 证书路径按宝塔实际填写,例如: + # ssl_certificate /www/server/panel/vhost/cert/soulapi.quwanzhi.com/fullchain.pem; + # ssl_certificate_key /www/server/panel/vhost/cert/soulapi.quwanzhi.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + +保存并重载 Nginx。 + +--- + +## 三、改完后自测 + +- 本机:curl -s https://soulapi.quwanzhi.com/health +- 或浏览器打开:https://soulapi.quwanzhi.com/health +应看到:{"status":"ok"} +- 打开 https://soulapi.quwanzhi.com/ 应看到“部署成功”页面。 diff --git a/开发文档/5、接口/在线支付对接文档.md b/开发文档/5、接口/在线支付对接文档.md new file mode 100644 index 00000000..27c8bf8c --- /dev/null +++ b/开发文档/5、接口/在线支付对接文档.md @@ -0,0 +1,319 @@ +# 在线支付对接文档 + +本文档根据当前项目中的支付相关代码与配置反向整理,供前端/第三方/运维对接使用。后端当前为 **soul-api(Go/Gin)**,支付业务逻辑可参考 **next-project** 中的实现。 + +--- + +## 一、概述 + +- **支付方式**:微信支付(Native 扫码 / JSAPI 小程序·公众号 / H5)、支付宝(WAP / Web / 扫码)。 +- **对接入口**:统一走 soul-api 的 `/api` 前缀(如 `https://your-api.com/api/...`)。 +- **回调**:支付平台(微信/支付宝)会主动 POST 到服务端配置的 notify 地址,需公网可访问且返回约定格式。 + +--- + +## 二、接口清单(soul-api) + +| 方法 | 路径 | 说明 | +|-----|------|------| +| POST | `/api/payment/create-order` | 创建支付订单,返回支付参数(二维码/链接/JSAPI 参数等) | +| GET | `/api/payment/methods` | 获取可用支付方式列表 | +| GET | `/api/payment/query` | 按交易号查询支付状态(轮询用) | +| GET | `/api/payment/status/:orderSn` | 按订单号查询订单支付状态 | +| POST | `/api/payment/verify` | 支付结果校验(可选) | +| POST | `/api/payment/callback` | 通用支付回调(可选,与各平台 notify 二选一或并存) | +| POST | `/api/payment/wechat/notify` | 微信支付异步通知 | +| POST | `/api/payment/alipay/notify` | 支付宝异步通知 | +| POST | `/api/payment/wechat/transfer/notify` | 微信转账/企业付款到零钱回调(若启用) | +| GET/POST | `/api/miniprogram/pay` | 小程序下单(创建订单 + 返回微信支付参数) | +| POST | `/api/miniprogram/pay/notify` | 小程序支付异步通知 | + +管理端(需鉴权): + +| 方法 | 路径 | 说明 | +|-----|------|------| +| GET/POST/PUT/DELETE | `/api/admin/payment` | 支付相关配置管理 | + +--- + +## 三、请求与响应约定 + +以下格式以 next-project 中已实现逻辑为对接规范,soul-api 实现时应与之兼容。 + +### 3.1 创建订单 `POST /api/payment/create-order` + +**请求体(JSON)** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| userId | string | 是 | 用户 ID | +| type | string | 是 | 购买类型:`section`(单章) / `fullbook`(全书) | +| sectionId | string | type=section 时 | 章节 ID,如 `1-1` | +| sectionTitle | string | 建议 | 章节标题,用于展示与订单描述 | +| amount | number | 是 | 金额(元),如 9.9 | +| paymentMethod | string | 是 | 支付方式:`wechat` / `alipay` | +| referralCode | string | 否 | 推荐人邀请码,用于分销 | + +**响应(JSON)** + +```json +{ + "code": 200, + "message": "订单创建成功", + "data": { + "orderSn": "20260209123456", + "tradeSn": "T2026020912000012345", + "userId": "user_xxx", + "type": "section", + "sectionId": "1-1", + "sectionTitle": "第一章", + "amount": 9.9, + "paymentMethod": "wechat", + "status": "created", + "createdAt": "2026-02-09T12:00:00.000Z", + "expireAt": "2026-02-09T12:30:00.000Z", + "paymentData": { + "type": "qrcode", + "payload": "weixin://wxpay/...", + "tradeSn": "T2026020912000012345", + "expiration": 1800 + }, + "gateway": "wechat_native" + } +} +``` + +- **paymentData.type**:`qrcode`(二维码内容/链接)、`url`(跳转链接)、`json`(JSAPI 等参数对象)。 +- **paymentData.payload**:微信 Native 为二维码链接;支付宝 WAP 为支付 URL;JSAPI 为 `{ timeStamp, nonceStr, package, signType, paySign }` 等。 +- **gateway**:用于后续轮询时传 `gateway`,如 `wechat_native`、`alipay_wap`。 + +**错误**:`code: 400` 表示缺少必要参数;`code: 500` 为服务器错误。 + +--- + +### 3.2 支付方式列表 `GET /api/payment/methods` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "methods": [ + { + "gateway": "wechat_native", + "name": "微信支付", + "icon": "wechat", + "enabled": true, + "available": true + } + ] + } +} +``` + +--- + +### 3.3 查询支付状态(轮询)`GET /api/payment/query` + +**Query** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| tradeSn | string | 是 | 创建订单时返回的 tradeSn | +| gateway | string | 否 | 指定网关,如 `wechat_native`、`alipay_wap`,不传则双通道查询 | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "tradeSn": "T2026020912000012345", + "status": "paid", + "platformSn": "4200001234567890", + "payAmount": 990, + "payTime": "2026-02-09T12:05:00.000Z", + "gateway": "wechat_native" + } +} +``` + +- **status**:`paying` 未支付,`paid` 已支付,`closed` 已关闭/退款等。 +- **payAmount**:单位「分」。**payTime** 为支付完成时间。 + +前端建议:每 3 秒轮询一次,最多约 60 次(约 3 分钟);收到 `status: "paid"` 后停止轮询并更新订单/解锁内容。 + +--- + +### 3.4 按订单号查状态 `GET /api/payment/status/:orderSn` + +**路径参数**:`orderSn` 为创建订单返回的订单号。 + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "orderSn": "20260209123456", + "status": "paid", + "paidAmount": 9.9, + "paidAt": "2026-02-09T12:05:00.000Z", + "paymentMethod": "wechat", + "tradeSn": "T2026020912000012345", + "productType": "section" + } +} +``` + +- **status**:与业务一致:`created`、`paying`、`paid`、`closed`、`refunded` 等。 + +--- + +### 3.5 支付校验 `POST /api/payment/verify` + +**请求体** + +| 字段 | 类型 | 说明 | +|------|------|------| +| orderId | string | 订单号 | +| paymentMethod | string | 支付方式 | +| transactionId | string | 第三方交易号(可选) | + +**响应**:成功时 `code: 0`,失败时非 0;用于前端在回调不确定时的二次校验(具体逻辑由后端实现)。 + +--- + +## 四、支付平台异步通知(回调) + +对接方需在微信支付/支付宝商户后台配置「支付结果通知 URL」,且必须为 **公网 HTTPS**。当前项目约定路径如下(以 soul-api 域名为准): + +| 支付方式 | 通知 URL | 说明 | +|----------|----------|------| +| 微信支付 | `https://your-api.com/api/payment/wechat/notify` | 统一下单/JSAPI/小程序等 | +| 支付宝 | `https://your-api.com/api/payment/alipay/notify` | 异步 notify | +| 微信转账 | `https://your-api.com/api/payment/wechat/transfer/notify` | 企业付款到零钱(若使用) | + +### 4.1 微信支付 notify + +- **方法**:POST +- **Content-Type**:`application/xml` +- **Body**:微信以 XML 推送,字段含 `return_code`、`result_code`、`out_trade_no`、`transaction_id`、`total_fee`、`time_end`、`sign` 等。 +- **验签**:使用商户密钥对微信参数做 MD5 签名校验(见 next-project `lib/payment/wechat.ts` 中 `verifySign`)。 +- **响应**:必须返回 XML,成功示例: + `` + 失败则返回 `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 实现对齐后上线。 + +--- + +*文档根据项目代码反向整理,若与最新代码不一致,以实际仓库为准。*