feat(payment): 管理端认证调试页对接支付宝通道认证

新增 ChannelAuth 调试页与 DevelopAuthApi,权限码补 develop:auth。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-09 17:12:07 +08:00
parent b6927ffe1e
commit 5afaf79cbe
5 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 认证调试 API
*/
export const DevelopAuthApi = {
/**
* 生成支付宝授权链接
*/
generateAuthUrl(): Promise<Result<AuthUrlResult>> {
return defHttp.post({ url: '/admin/develop/auth/generate-auth-url' });
},
/**
* 通过查询码获取认证结果
*/
queryAuthResult(queryCode: string): Promise<Result<AuthResult>> {
return defHttp.get({
url: '/admin/develop/auth/query-auth-result',
params: { queryCode },
});
},
};
/** 授权链接结果 */
export interface AuthUrlResult {
/** 授权访问链接 */
authUrl?: string;
/** 查询标识码 */
queryCode?: string;
}
/** 认证结果 */
export interface AuthResult {
/** OpenId */
openId?: string;
/** 用户ID */
userId?: string;
/** AccessToken */
accessToken?: string;
/** 状态 waiting/success/not_exist */
status?: string;
}

View File

@@ -205,6 +205,10 @@ export const PermCodes = {
Sign: {
VIEW: 'develop:sign:view',
},
/** 认证调试 menuCode=develop:auth */
Auth: {
VIEW: 'develop:auth:view',
},
},
/** 设备管理(独立命名空间) */

View File

@@ -149,5 +149,35 @@
"inputPrivateKey": "Please set the merchant private key first",
"copySuccess": "Copied"
}
},
"auth": {
"title": "Channel Auth Debug",
"tag": {
"alipay": "Alipay"
},
"guide": {
"title": "How to use",
"desc": "Generate an auth link and scan with Alipay app. Results appear automatically (requires platform Alipay config and payment gateway base URL)."
},
"btn": {
"generate": "Generate Auth Link",
"copy": "Copy"
},
"qr": {
"tip": "Scan with Alipay to authorize",
"empty": "No QR code yet"
},
"result": {
"divider": "Auth Result",
"openId": "OpenId",
"userId": "UserId",
"accessToken": "Access Token"
},
"msg": {
"success": "Auth result received",
"notExist": "Auth result not found or expired",
"copySuccess": "Copied",
"copyFail": "Copy failed"
}
}
}

View File

@@ -149,5 +149,35 @@
"inputPrivateKey": "请先设置商户私钥",
"copySuccess": "复制成功"
}
},
"auth": {
"title": "渠道授权调试工具",
"tag": {
"alipay": "支付宝"
},
"guide": {
"title": "使用说明",
"desc": "点击生成授权链接后,使用支付宝 App 扫码完成授权。结果将自动回显(依赖「三方平台管理」中的平台支付宝配置与支付网关前端地址)。"
},
"btn": {
"generate": "生成授权链接",
"copy": "复制"
},
"qr": {
"tip": "请使用支付宝扫码授权",
"empty": "暂未生成二维码"
},
"result": {
"divider": "授权结果",
"openId": "用户标识 (OpenId)",
"userId": "用户ID (UserId)",
"accessToken": "Access Token"
},
"msg": {
"success": "获取授权结果成功",
"notExist": "获取授权结果失败(查询码不存在或已过期)",
"copySuccess": "复制成功",
"copyFail": "复制失败"
}
}
}

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { AuthResult, AuthUrlResult } from '#/api/payment/develop/developAuth.api';
import { onBeforeUnmount, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import { useIntervalFn } from '@vueuse/core';
import { DevelopAuthApi } from '#/api/payment/develop/developAuth.api';
import { QrCode } from '#/components/qrcode';
import { useMessage } from '#/hooks/useMessage';
defineOptions({ name: 'ChannelAuth' });
/** 认证状态 */
const AuthStatus = {
WAITING: 'waiting',
SUCCESS: 'success',
NOT_EXIST: 'not_exist',
} as const;
const { message } = useMessage();
const loading = ref(false);
const authUrl = ref<AuthUrlResult>({});
const authResult = ref<AuthResult>({});
// 轮询查询认证结果
const { pause, resume } = useIntervalFn(
async () => {
const queryCode = authUrl.value.queryCode;
if (!queryCode) {
pause();
return;
}
try {
const { data } = await DevelopAuthApi.queryAuthResult(queryCode);
authResult.value = data ?? {};
if (data?.status === AuthStatus.SUCCESS) {
message.success($t('payment.develop.auth.msg.success'));
pause();
} else if (data?.status === AuthStatus.NOT_EXIST) {
message.error($t('payment.develop.auth.msg.notExist'));
pause();
}
} catch {
pause();
}
},
3000,
{ immediate: false },
);
onBeforeUnmount(() => {
pause();
});
/** 生成授权链接并开始轮询 */
async function handleGenerate() {
pause();
authResult.value = {};
authUrl.value = {};
loading.value = true;
try {
const { data } = await DevelopAuthApi.generateAuthUrl();
authUrl.value = data ?? {};
if (data?.queryCode) {
resume();
}
} finally {
loading.value = false;
}
}
/** 复制文本 */
async function copy(value?: string) {
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
message.success($t('payment.develop.auth.msg.copySuccess'));
} catch {
message.error($t('payment.develop.auth.msg.copyFail'));
}
}
/** 是否已有结果字段可展示 */
function hasResult(result: AuthResult) {
return !!(result.openId || result.userId || result.accessToken);
}
</script>
<template>
<div class="channel-auth-debug p-4">
<a-card variant="borderless" class="shadow-sm" :title="$t('payment.develop.auth.title')">
<template #extra>
<a-tag color="blue">OAuth2.0</a-tag>
<a-tag color="processing">{{ $t('payment.develop.auth.tag.alipay') }}</a-tag>
</template>
<a-row :gutter="32">
<!-- 左侧操作区 -->
<a-col :xs="24" :lg="10">
<a-alert
:message="$t('payment.develop.auth.guide.title')"
:description="$t('payment.develop.auth.guide.desc')"
type="info"
show-icon
class="mb-6"
/>
<a-button type="primary" block size="large" :loading="loading" @click="handleGenerate">
<template #icon>
<IconifyIcon icon="lucide:link" />
</template>
{{ $t('payment.develop.auth.btn.generate') }}
</a-button>
<div v-if="authUrl.authUrl" class="mt-4 rounded border border-dashed p-3 text-xs break-all text-gray-500">
{{ authUrl.authUrl }}
</div>
</a-col>
<!-- 右侧二维码与结果 -->
<a-col :xs="24" :lg="14">
<div
class="display-section flex h-full flex-col items-center justify-start rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6"
>
<div v-if="authUrl.authUrl" class="text-center">
<div class="inline-block rounded-lg bg-white p-4 shadow-md">
<QrCode :value="authUrl.authUrl" :width="230" :margin="0" />
</div>
<p class="mt-4 flex items-center justify-center text-gray-500">
<IconifyIcon icon="lucide:scan-line" class="mr-2" />
{{ $t('payment.develop.auth.qr.tip') }}
</p>
</div>
<div v-else class="flex flex-col items-center justify-center py-20 text-gray-400">
<IconifyIcon icon="lucide:qr-code" class="opacity-20" :style="{ fontSize: '80px' }" />
<p class="mt-4">{{ $t('payment.develop.auth.qr.empty') }}</p>
</div>
<div v-if="hasResult(authResult)" class="mt-8 w-full">
<a-divider orientation="left">{{ $t('payment.develop.auth.result.divider') }}</a-divider>
<div class="space-y-4">
<div v-if="authResult.openId" class="result-card rounded border bg-white p-4 shadow-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-bold tracking-wider text-gray-400 uppercase">
{{ $t('payment.develop.auth.result.openId') }}
</span>
<a-button type="link" size="small" @click="copy(authResult.openId)">
{{ $t('payment.develop.auth.btn.copy') }}
</a-button>
</div>
<div class="font-mono text-sm break-all text-blue-600">{{ authResult.openId }}</div>
</div>
<div v-if="authResult.userId" class="result-card rounded border bg-white p-4 shadow-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-bold tracking-wider text-gray-400 uppercase">
{{ $t('payment.develop.auth.result.userId') }}
</span>
<a-button type="link" size="small" @click="copy(authResult.userId)">
{{ $t('payment.develop.auth.btn.copy') }}
</a-button>
</div>
<div class="font-mono text-sm break-all text-blue-600">{{ authResult.userId }}</div>
</div>
<div v-if="authResult.accessToken" class="result-card rounded border bg-white p-4 shadow-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-bold tracking-wider text-gray-400 uppercase">
{{ $t('payment.develop.auth.result.accessToken') }}
</span>
<a-button type="link" size="small" @click="copy(authResult.accessToken)">
{{ $t('payment.develop.auth.btn.copy') }}
</a-button>
</div>
<div class="font-mono text-sm break-all text-blue-600">{{ authResult.accessToken }}</div>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</a-card>
</div>
</template>
<style scoped>
.channel-auth-debug {
min-height: calc(100vh - 80px);
background-color: #f0f2f5;
}
.display-section {
min-height: 500px;
}
.font-mono {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
.result-card {
transition: border-color 0.2s;
}
.result-card:hover {
border-color: #1677ff;
}
</style>