更新项目配置,调整 .next 目录的清理脚本以支持多个子目录的删除,优化提现功能的环境变量配置,确保与现有支付配置一致。同时,增强 API 日志记录以便于调试,更新文档以反映新的环境变量使用方式,提升系统的可维护性和用户体验。

This commit is contained in:
乘风
2026-02-07 15:30:31 +08:00
parent 8e67eb5d62
commit de10a203b3
8 changed files with 130 additions and 32 deletions

View File

@@ -43,7 +43,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
// 简化菜单:按功能归类,保留核心功能
// PDF需求分账管理、分销管理、订单管理三合一 → 交易中心
const menuItems = [
{ icon: LayoutDashboard, label: "数据概览147", href: "/admin" },
{ icon: LayoutDashboard, label: "数据概览1888", href: "/admin" },
{ icon: BookOpen, label: "内容管理", href: "/admin/content" },
{ icon: Users, label: "用户管理", href: "/admin/users" },
{ icon: Wallet, label: "交易中心", href: "/admin/distribution" }, // 合并:分销+订单+提现

View File

@@ -103,6 +103,7 @@ export async function PUT(request: Request) {
if (action === 'approve') {
console.log(STEP, '3. 发起转账(用户确认模式)')
console.log(STEP, '审核传入: id=', id, 'userId=', userId, 'amount=', amount, 'openid=', openid ? `${openid.slice(0, 8)}...` : '(空)')
if (!openid) {
return NextResponse.json({
success: false,
@@ -110,6 +111,7 @@ export async function PUT(request: Request) {
}, { status: 400 })
}
const amountFen = Math.round(amount * 100)
console.log(STEP, '调用 createTransferUserConfirm: outBillNo=', id, 'amountFen=', amountFen, 'openid 长度=', openid.length)
const transferResult = await createTransferUserConfirm({
openid,
amountFen,

View File

@@ -18,13 +18,34 @@ export interface WechatTransferConfig {
certSerialNo: string
}
// 与小程序支付、lib/payment/config 保持一致,复用同一套 env
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
/** 从 apiclient_cert.pem 读取证书序列号(与 lib/payment 的 WECHAT_CERT_PATH 复用) */
function getCertSerialNoFromPath(certPath: string): string {
const p = path.isAbsolute(certPath) ? certPath : path.join(process.cwd(), certPath)
const pem = fs.readFileSync(p, 'utf8')
const cert = new crypto.X509Certificate(pem)
const raw = (cert.serialNumber || '').replace(/:/g, '').replace(/^0+/, '') || ''
return raw.toUpperCase()
}
function getConfig(): WechatTransferConfig {
const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || ''
const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || DEFAULT_MCH_ID
const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || DEFAULT_APP_ID
const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || ''
const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || ''
const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || ''
const certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
let certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
const certPath = process.env.WECHAT_CERT_PATH || ''
if (!certSerialNo && certPath) {
try {
certSerialNo = getCertSerialNoFromPath(certPath)
} catch (e) {
console.warn('[wechat-transfer] 从证书文件读取序列号失败:', (e as Error).message)
}
}
return {
mchId,
appId,
@@ -193,8 +214,24 @@ export async function createTransferUserConfirm(
params: CreateTransferUserConfirmParams
): Promise<CreateTransferUserConfirmResult> {
const cfg = getConfig()
if (!cfg.mchId || !cfg.appId || !cfg.certSerialNo) {
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' }
if (!cfg.mchId || !cfg.appId) {
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整:缺少商户号或 AppID' }
}
if (!cfg.certSerialNo) {
const certPath = process.env.WECHAT_CERT_PATH || ''
const hint = certPath
? `已配置 WECHAT_CERT_PATH=${certPath} 但读取序列号失败,请检查文件存在且为有效 PEM。或直接在 .env 配置 WECHAT_MCH_CERT_SERIAL_NOopenssl x509 -in apiclient_cert.pem -noout -serial 获取)`
: '请在 .env 中配置 WECHAT_CERT_PATHapiclient_cert.pem 路径),或配置 WECHAT_MCH_CERT_SERIAL_NO证书序列号'
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: `微信转账配置不完整:缺少证书序列号。${hint}` }
}
try {
getPrivateKey()
} catch (e) {
return {
success: false,
errorCode: 'CONFIG_ERROR',
errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATHapiclient_key.pem 路径)或 WECHAT_MCH_PRIVATE_KEY`,
}
}
const urlPath = '/v3/fund-app/mch-transfer/transfer-bills'
@@ -218,6 +255,14 @@ export async function createTransferUserConfirm(
const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr)
const authorization = buildAuthorization(timestamp, nonce, signature)
// 发起请求前打印:便于区分是请求微信缺参,还是管理端审核传参问题
console.log('[wechat-transfer] ========== 请求微信支付(用户确认模式)==========')
console.log('[wechat-transfer] URL:', `${BASE_URL}${urlPath}`)
console.log('[wechat-transfer] 请求体 body:', JSON.stringify(body, null, 2))
console.log('[wechat-transfer] 当前配置: mchId=', cfg.mchId, 'appId=', cfg.appId, 'certSerialNo=', cfg.certSerialNo ? `${cfg.certSerialNo.slice(0, 8)}...` : '(空)')
console.log('[wechat-transfer] notify_url:', body.notify_url)
console.log('[wechat-transfer] ========================================')
const res = await fetch(`${BASE_URL}${urlPath}`, {
method: 'POST',
headers: {
@@ -229,6 +274,7 @@ export async function createTransferUserConfirm(
body: bodyStr,
})
const data = (await res.json()) as Record<string, unknown>
console.log('[wechat-transfer] 微信响应 status=', res.status, 'body=', JSON.stringify(data, null, 2))
if (res.ok && res.status >= 200 && res.status < 300) {
const state = (data.state as string) || ''
return {

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -6,6 +6,7 @@
"scripts": {
"build": "node scripts/clean-standalone.js && next build && node scripts/write-standalone-warning.js",
"postbuild": "node scripts/prepare-standalone.js",
"clean": "node -e \"const fs=require('fs'),p=require('path').join(__dirname,'.next');fs.existsSync(p)&&fs.rmSync(p,{recursive:true,force:true})&&console.log('已删除 .next')\"",
"dev": "next dev -p 3006",
"lint": "eslint .",
"start": "node scripts/start-standalone.js"

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
/**
* 构建前清理 .next/standalone,避免 Next.js build 时 EBUSY目录被占用
* 构建前清理 .next/standalone、.next/static、.next/cache
* - 避免 Next.js build 时 EBUSY目录被占用
* - 避免混入 next dev 的 Turbopack 产物,导致打包后加载 chunk 500 / Failed to load chunk
* 若曾运行 pnpm start请先 Ctrl+C 停掉再 build
*/
@@ -8,7 +10,12 @@ const fs = require('fs');
const path = require('path');
const rootDir = path.join(__dirname, '..');
const standaloneDir = path.join(rootDir, '.next', 'standalone');
const nextDir = path.join(rootDir, '.next');
const dirsToClean = [
path.join(nextDir, 'standalone'),
path.join(nextDir, 'static'),
path.join(nextDir, 'cache'),
];
const RETRIES = 5;
const DELAY_MS = 2000;
@@ -17,29 +24,39 @@ function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
if (!fs.existsSync(standaloneDir)) {
return;
function removeDir(dirPath) {
if (!fs.existsSync(dirPath)) return true;
try {
fs.rmSync(dirPath, { recursive: true, force: true, maxRetries: 3 });
return true;
} catch (e) {
throw e;
}
}
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try {
fs.rmSync(standaloneDir, { recursive: true, force: true, maxRetries: 3 });
console.log('[clean-standalone] 已删除 .next/standalone');
return;
} catch (e) {
if (e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'ENOTEMPTY') {
if (attempt < RETRIES) {
console.warn('[clean-standalone] 目录被占用,%ds 后重试 (%d/%d)...', DELAY_MS / 1000, attempt, RETRIES);
await sleep(DELAY_MS);
async function main() {
for (const dirPath of dirsToClean) {
if (!fs.existsSync(dirPath)) continue;
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try {
removeDir(dirPath);
const name = path.relative(nextDir, dirPath) || dirPath;
console.log('[clean-standalone] 已删除 .next/' + name);
break;
} catch (e) {
if (e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'ENOTEMPTY') {
if (attempt < RETRIES) {
console.warn('[clean-standalone] 目录被占用,%ds 后重试 (%d/%d)...', DELAY_MS / 1000, attempt, RETRIES);
await sleep(DELAY_MS);
} else {
console.error('[clean-standalone] 无法删除:', dirPath);
console.error(' 请先关闭pnpm start / next dev、或占用该目录的其它程序');
console.error(' 然后重新执行pnpm build');
process.exit(1);
}
} else {
console.error('[clean-standalone] 无法删除 .next/standalone目录被占用');
console.error(' 请先关闭pnpm start、或占用该目录的其它程序如资源管理器、杀毒');
console.error(' 然后重新执行pnpm build');
process.exit(1);
throw e;
}
} else {
throw e;
}
}
}

View File

@@ -61,6 +61,17 @@ if (!fs.existsSync(chunksDir)) {
process.exit(1);
}
// 禁止把开发态 Turbopack 产物打进 standalone否则线上会 500 / Failed to load chunk
const chunkFiles = fs.readdirSync(chunksDir);
const turbopackChunk = chunkFiles.find((f) => f.startsWith('turbopack-') && f.endsWith('.js'));
if (turbopackChunk) {
console.error('❌ 错误:检测到开发态产物 ' + turbopackChunk);
console.error(' 当前 .next/static 来自 next dev不能用于线上。请在本机执行');
console.error(' pnpm run clean && pnpm build');
console.error(' 然后重新部署 .next/standalone 整个目录。');
process.exit(1);
}
console.log(' public → .next/standalone/public');
copyDir(publicSrc, publicDst);

View File

@@ -98,7 +98,28 @@
| **平台证书(wechat_pay_pub_key)** | 用于验证回调签名 | 下载或通过API获取 |
| **小程序AppId** | 小程序标识 | 小程序管理后台 |
### 2. 提取证书序列号
### 2. 项目环境变量(.env— 复用现有支付配置
**商家转账到零钱**`lib/wechat-transfer.ts`)已与 **lib/payment/config**、小程序支付共用同一套环境变量:
| 环境变量 | 说明 | 复用说明 |
|---------|------|----------|
| `WECHAT_MCH_ID` / `WECHAT_MCHID` | 商户号 | 不填则用默认 1318592501与 pay 一致) |
| `WECHAT_APP_ID` / `WECHAT_APPID` | 小程序 AppID | 不填则用默认 wxb8bbb2b10dec74aa |
| **`WECHAT_CERT_PATH`** | 商户证书路径apiclient_cert.pem | **与 payment 共用**;配置后证书序列号会**自动从该文件读取**,无需再配 WECHAT_MCH_CERT_SERIAL_NO |
| **`WECHAT_KEY_PATH`** | 商户私钥路径apiclient_key.pem | **与 payment 共用** |
| `WECHAT_API_V3_KEY` / `WECHAT_MCH_KEY` | APIv3 密钥(回调解密) | 与 payment 的 mchKey 共用 |
**只需在 .env 中配置与现有支付相同的证书路径即可**(若 payment 已配 `WECHAT_CERT_PATH``WECHAT_KEY_PATH`,转账会直接复用):
```env
WECHAT_CERT_PATH=./certs/apiclient_cert.pem
WECHAT_KEY_PATH=./certs/apiclient_key.pem
```
若未配置证书路径,也可单独配置:`WECHAT_MCH_CERT_SERIAL_NO`(证书序列号)、`WECHAT_MCH_PRIVATE_KEY``WECHAT_KEY_PATH`(私钥)。
### 3. 提取证书序列号
**使用OpenSSL命令**
@@ -121,7 +142,7 @@ echo strtoupper(dechex($certData['serialNumber']));
?>
```
### 3. 配置IP白名单
### 4. 配置IP白名单
路径:微信商户平台 → 账户中心 → API安全 → IP配置
@@ -133,7 +154,7 @@ echo strtoupper(dechex($certData['serialNumber']));
curl ifconfig.me
```
### 4. 配置转账场景
### 5. 配置转账场景
路径:微信商户平台 → 产品中心 → 商家转账到零钱 → 前往功能
@@ -141,7 +162,7 @@ curl ifconfig.me
- **1000**:现金营销
- **1005**:营销活动
### 5. 环境要求
### 6. 环境要求
- PHP >= 7.0
- OpenSSL 扩展(必须)