fix: payment

This commit is contained in:
ggyy
2026-04-25 15:44:01 +08:00
parent 77d9fc4351
commit ddec22be49
14 changed files with 106 additions and 37 deletions

View File

@@ -300,5 +300,8 @@ bun dev
4. 在搜索框输入关键词过滤日志,例如:
- `email.notify_order_paid.config_failed` — 支付后邮件配置获取失败
- `email.send.failed` — 邮件发送失败
- `email.order_paid.failed` — 支付成功后发送邮件通知失败
- `payment.notify.route_exception` — 支付回调路由异常
- `payment.notify.context_missing` — 支付回调缺少数据库上下文
- `payment.notify.diagnostic` — 支付回调校验异常诊断(签名错误、金额不匹配等)
- `bepusdt.verify_notify` — BEpusdt 回调原始 payloaddebug 级别)

View File

@@ -0,0 +1,34 @@
<template>
<div class="relative">
<input
:type="visible ? 'text' : 'password'"
:value="modelValue"
class="input input-bordered w-full pr-10"
v-bind="$attrs"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
@click="visible = !visible"
>
<svg v-if="visible" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" /><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.064 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineProps<{ modelValue: string }>();
defineEmits<{ "update:modelValue": [value: string] }>();
defineOptions({ inheritAttrs: false });
const visible = ref(false);
</script>

View File

@@ -1,5 +1,24 @@
# 公共组件文档
## SecretInput
带显示/隐藏切换的密钥输入框用于密码、API Secret 等敏感字段。
### Props
| 属性 | 类型 | 说明 |
|------|------|------|
| `modelValue` | `string` | 输入值v-model |
支持透传所有原生 `input` 属性(如 `placeholder``disabled` 等)。
### 基本用法
```components/SecretInput.vue#L1-3
<SecretInput v-model="form.appSecret" placeholder="请输入 App Secret" />
```
## DataTable
通用带翻页的表格组件,基于 daisyUI `table` 样式。

View File

@@ -46,7 +46,7 @@ export function getPaymentProviderLabel(provider: string) {
case "EPAY":
return "易支付";
case "BEPUSDT":
return "USDT";
return "BEpusdt";
default:
return provider;
}

View File

@@ -16,7 +16,7 @@ export interface AlipayConfig {
returnUrl?: string;
}
const DEFAULT_GATEWAY = "https://openapi.alipay.com/gateway.do";
function pemToBase64(pem: string) {
return pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
@@ -65,7 +65,7 @@ function buildSignString(params: Record<string, string>) {
export function createAlipayAdapter(config: AlipayConfig): PaymentProviderAdapter {
return {
async createPayment(input) {
const gateway = config.baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_GATEWAY;
const gateway = `${config.baseUrl?.trim().replace(/\/+$/, "")}/gateway.do`;
if (!config.alipayAppId || !config.alipayPrivateKey) {
throw badRequestError("支付宝配置不完整", "ALIPAY_CONFIG_INCOMPLETE");
@@ -136,7 +136,7 @@ export async function queryAlipayTrade(config: AlipayConfig, outTradeNo: string)
throw badRequestError("支付宝配置不完整", "ALIPAY_CONFIG_INCOMPLETE");
}
const gateway = config.baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_GATEWAY;
const gateway = `${config.baseUrl?.trim().replace(/\/+$/, "")}/gateway.do`;
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
const params: Record<string, string> = {
app_id: config.alipayAppId,

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { badRequestError, externalServiceError } from "../../lib/app-error";
import { logger } from "../../lib/logger";
import type { PaymentProviderAdapter } from "./provider";
interface BepusdtConfig {
@@ -40,18 +41,8 @@ export function createBepusdtAdapter(config: BepusdtConfig): PaymentProviderAdap
};
const signature = signBepusdt(payload, config.appSecret);
const response = await fetch(`${normalizeBaseUrl(config.baseUrl)}/api/v1/order/create-transaction`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
...payload,
signature,
}),
});
const json = (await response.json()) as {
type BepusdtResponse = {
status_code?: number;
message?: string;
data?: {
@@ -60,7 +51,23 @@ export function createBepusdtAdapter(config: BepusdtConfig): PaymentProviderAdap
};
};
if (!response.ok || json.status_code !== 200 || !json.data?.payment_url) {
let json: BepusdtResponse;
try {
const response = await fetch(`${normalizeBaseUrl(config.baseUrl)}/api/v1/order/create-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, signature }),
});
const text = (await response.text()).replace(/^\uFEFF/, "");
json = JSON.parse(text) as BepusdtResponse;
} catch (err) {
throw externalServiceError(
`BEpusdt 请求失败: ${err instanceof Error ? err.message : String(err)}`,
"BEPUSDT_INVALID_RESPONSE"
);
}
if (json.status_code !== 200 || !json.data?.payment_url) {
throw externalServiceError(json.message || "BEpusdt 创建支付失败", "BEPUSDT_CREATE_PAYMENT_FAILED");
}
@@ -80,11 +87,13 @@ export function createBepusdtAdapter(config: BepusdtConfig): PaymentProviderAdap
};
}
logger.debug("bepusdt.verify_notify", { payload });
const signature = payload.signature || "";
const unsignedPayload = { ...payload };
delete unsignedPayload.signature;
const expected = signBepusdt(unsignedPayload, config.appSecret);
const status = payload.status === "2" ? "PAID" : payload.status === "3" ? "FAILED" : "PENDING";
const statusVal = String(payload.status);
const status = statusVal === "2" ? "PAID" : statusVal === "3" ? "FAILED" : "PENDING";
return {
isValid: signature === expected,

View File

@@ -19,7 +19,7 @@ import { notifyOrderPaid as _notifyOrderPaid } from "../email/service";
const defaultPaymentConfigs: Record<PaymentProvider, PaymentConfigValue> = {
BEPUSDT: {
provider: "BEPUSDT",
name: "USDT",
name: "BEpusdt",
isEnabled: false,
baseUrl: "",
appId: "",
@@ -41,7 +41,7 @@ const defaultPaymentConfigs: Record<PaymentProvider, PaymentConfigValue> = {
provider: "ALIPAY",
name: "支付宝",
isEnabled: false,
baseUrl: "",
baseUrl: "https://openapi.alipay.com",
alipayAppId: "",
alipayPrivateKey: "",
alipayPublicKey: "",

View File

@@ -212,11 +212,11 @@
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">API Key</span>
<input v-model="configForm.apiKey" class="input input-bordered w-full" />
<SecretInput v-model="configForm.apiKey" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Secret Key</span>
<input v-model="configForm.secretKey" class="input input-bordered w-full" :disabled="configForm.apiProvider !== 'MAILJET'" :placeholder="configForm.apiProvider === 'MAILJET' ? 'Mailjet Secret Key' : 'Brevo 不需要该字段'" />
<SecretInput v-model="configForm.secretKey" :disabled="configForm.apiProvider !== 'MAILJET'" :placeholder="configForm.apiProvider === 'MAILJET' ? 'Mailjet Secret Key' : 'Brevo 不需要该字段'" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">超时(ms)</span>
@@ -253,7 +253,7 @@
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">SMTP 密码</span>
<input v-model="configForm.smtpPassword" class="input input-bordered w-full" />
<SecretInput v-model="configForm.smtpPassword" />
</label><label class="flex flex-col gap-1.5">
<span class="label-text font-medium">认证方式</span>
<select v-model="configForm.smtpAuthType" class="select select-bordered w-full">
@@ -480,6 +480,7 @@
</template>
<script setup lang="ts">
import SecretInput from "../../../components/SecretInput.vue";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref, computed } from "vue";
import { useData } from "vike-vue/useData";

View File

@@ -11,7 +11,7 @@
<option value="">全部支付方式</option>
<option value="EPAY">易支付</option>
<option value="ALIPAY">支付宝</option>
<option value="BEPUSDT">USDT</option>
<option value="BEPUSDT">BEpusdt</option>
</select>
<select v-model="filter.status" class="select select-sm select-bordered w-32">
<option value="">全部状态</option>

View File

@@ -5,11 +5,11 @@
</div>
<div role="tablist" class="tabs tabs-border">
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'BEPUSDT' }" @click="activeTab = 'BEPUSDT'">
BEpusdt / USDT
BEpusdt
<span v-if="localConfigs.BEPUSDT?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'EPAY' }" @click="activeTab = 'EPAY'">
Epay / 聚合支付
Epay
<span v-if="localConfigs.EPAY?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'ALIPAY' }" @click="activeTab = 'ALIPAY'">
@@ -17,9 +17,9 @@
<span v-if="localConfigs.ALIPAY?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
</div>
<PaymentConfigCard v-if="activeTab === 'BEPUSDT'" provider="BEPUSDT" title="BEpusdt / USDT" :initial-value="localConfigs.BEPUSDT" @saved="localConfigs.BEPUSDT = $event" />
<PaymentConfigCard v-if="activeTab === 'EPAY'" provider="EPAY" title="Epay / 聚合支付" :initial-value="localConfigs.EPAY" @saved="localConfigs.EPAY = $event" />
<PaymentConfigCard v-if="activeTab === 'ALIPAY'" provider="ALIPAY" title="支付宝" :initial-value="localConfigs.ALIPAY" @saved="localConfigs.ALIPAY = $event" />
<PaymentConfigCard v-if="activeTab === 'BEPUSDT'" provider="BEPUSDT" title="Usdt/Usdc(数字货币)" :initial-value="localConfigs.BEPUSDT" @saved="localConfigs.BEPUSDT = $event" />
<PaymentConfigCard v-if="activeTab === 'EPAY'" provider="EPAY" title="易支付(聚合支付)" :initial-value="localConfigs.EPAY" @saved="localConfigs.EPAY = $event" />
<PaymentConfigCard v-if="activeTab === 'ALIPAY'" provider="ALIPAY" title="支付宝(官方)" :initial-value="localConfigs.ALIPAY" @saved="localConfigs.ALIPAY = $event" />
</section>
</template>

View File

@@ -18,8 +18,8 @@
<input v-model="form.name" class="input input-bordered w-full" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">网关地址</span>
<input v-model="form.baseUrl" class="input input-bordered w-full" placeholder="https://example.com" />
<span class="label-text font-medium">网关地址(仅需填写主域名系统处理接口)</span>
<input v-model="form.baseUrl" class="input input-bordered w-full" placeholder="https://pay.example.com (末尾请勿加斜杠)" />
</label>
</div>
@@ -67,7 +67,7 @@ const props = defineProps<{
}>();
const form = reactive({
name: props.initialValue?.name ?? (props.provider === 'BEPUSDT' ? 'USDT' : '聚合支付'),
name: props.initialValue?.name ?? (props.provider === 'BEPUSDT' ? 'BEpusdt' : '聚合支付'),
isEnabled: props.initialValue?.isEnabled ?? false,
baseUrl: props.initialValue?.baseUrl ?? '',
notifyUrl: props.initialValue?.notifyUrl ?? '',

View File

@@ -8,16 +8,17 @@
</div>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">应用私钥PKCS#8去掉头尾和换行</span>
<textarea v-model="modelValue.alipayPrivateKey" class="textarea textarea-bordered w-full font-mono text-xs" rows="4" placeholder="MIIEvgIBADANBgkq..." />
<SecretInput v-model="modelValue.alipayPrivateKey" placeholder="MIIEvgIBADANBgkq..." />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">支付宝公钥用于验签去掉头尾和换行</span>
<textarea v-model="modelValue.alipayPublicKey" class="textarea textarea-bordered w-full font-mono text-xs" rows="3" placeholder="MIIBIjANBgkq..." />
<SecretInput v-model="modelValue.alipayPublicKey" placeholder="MIIBIjANBgkq..." />
</label>
<p class="text-xs text-base-content/60">密钥生成工具<a href="https://opendocs.alipay.com/common/02kipl" target="_blank" class="link">支付宝密钥工具</a></p>
</div>
</template>
<script setup lang="ts">
import SecretInput from "../../../../components/SecretInput.vue";
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -6,11 +6,12 @@
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">App Secret</span>
<input v-model="modelValue.appSecret" class="input input-bordered w-full" />
<SecretInput v-model="modelValue.appSecret" />
</label>
</div>
</template>
<script setup lang="ts">
import SecretInput from "../../../../components/SecretInput.vue";
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -6,7 +6,7 @@
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Key</span>
<input v-model="modelValue.key" class="input input-bordered w-full" />
<SecretInput v-model="modelValue.key" />
</label>
</div>
<p class="text-xs text-base-content/60">
@@ -15,5 +15,6 @@
</template>
<script setup lang="ts">
import SecretInput from "../../../../components/SecretInput.vue";
defineProps<{ modelValue: Record<string, any> }>();
</script>