feat(lakala): 拉卡拉服务商配置与通道商户管理(管理端)

- 新增拉卡拉服务商配置管理页(LakalaManage)与编辑页调整
- api 补充通道商户/支付配置接口, locale 中英文同步
- perm-codes 新增 payment:lakala:isv 权限码
- ProductDetailDispatch 分发 lakala_pay 至拉卡拉管理页
This commit is contained in:
DaxPay Dev
2026-07-05 23:58:00 +08:00
parent 52712f0db4
commit 95f8b6b7ec
8 changed files with 290 additions and 114 deletions

View File

@@ -3,13 +3,50 @@ import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 拉卡拉通道商户创建 API
* 拉卡拉通道商户绑定 API
*/
export const ChannelMerchantLakalaApi = {
export const LakalaChannelMerchantApi = {
/**
* 根据通道商户号查询拉卡拉通道商户配置
*/
findByChannelMchNo(channelMchNo: string): Promise<Result<LakalaIsvChannelMerchant>> {
return defHttp.get({
url: '/admin/lakala/isv-channel-merchant/find-by-channel-mch-no',
params: { channelMchNo },
});
},
/**
* 创建拉卡拉通道商户
*/
create(data: Record<string, any>): Promise<Result<void>> {
return defHttp.post({ url: '/admin/lakala/channel-merchant/create', data });
create(data: LakalaIsvChannelMerchantCreateParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/lakala/isv-channel-merchant/create', data });
},
};
/** 拉卡拉通道商户绑定 */
export interface LakalaIsvChannelMerchant {
/** 主键 */
id?: string;
/** 通道商户号 */
channelMchNo?: string;
/** 所属支付产品 */
product?: string;
/** 拉卡拉商户编号 */
lakalaMchNo?: string;
/** 终端号 */
termNo?: string;
}
/** 拉卡拉通道商户创建参数 */
export interface LakalaIsvChannelMerchantCreateParam {
/** 商户号 */
mchNo: string;
/** 通道商户名称 */
channelMerchantName: string;
/** 所属支付产品 */
product: string;
/** 拉卡拉商户编号 */
lakalaMchNo: string;
/** 终端号 */
termNo?: string;
}

View File

@@ -1,43 +1,42 @@
import type { MchEntity, Result } from '#/types/web';
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 拉卡拉产品支付配置 API
* 拉卡拉服务商密钥配置 API
*/
export const LakalaPayConfigApi = {
/**
* 查询拉卡拉产品配置详情
* 查询拉卡拉服务商密钥配置
* 平台为唯一服务商,密钥全局唯一
*/
findConfig(isvNo: string, product: string, sandbox: boolean): Promise<Result<LakalaProductConfig>> {
return defHttp.get({ url: '/admin/lakala/product-pay-config/find-config', params: { isvNo, product, sandbox } });
findConfig(product: string): Promise<Result<LakalaIsvKeyConfig>> {
return defHttp.get({ url: '/admin/lakala/isv-key-config/find-config', params: { product } });
},
/**
* 保存拉卡拉产品配置
* 保存拉卡拉服务商密钥配置
*/
saveConfig(data: LakalaProductConfig): Promise<Result<void>> {
return defHttp.post({ url: '/admin/lakala/product-pay-config/save-config', data });
saveConfig(data: LakalaIsvKeyConfig): Promise<Result<void>> {
return defHttp.post({ url: '/admin/lakala/isv-key-config/save-config', data });
},
};
/** 拉卡拉产品支付配置 */
export interface LakalaProductConfig extends MchEntity {
/** 是否启用 */
enable?: boolean;
/** 沙箱环境 */
sandbox?: boolean;
/** 拉卡拉服务商密钥配置 */
export interface LakalaIsvKeyConfig {
/** 主键 */
id?: string;
/** 产品编码 */
product?: string;
/** 拉卡拉应用编号 */
lklAppId?: string;
/** 商户证书序列号 */
mchSerialNo?: string;
/** 私钥 */
/** 商户RSA私钥(PEM) */
privateKey?: string;
/** 公钥 */
/** 拉卡拉RSA公钥证书(PEM) */
publicKey?: string;
/** sm4密钥 */
/** SM4密钥(进件敏感字段加密用) */
sm4Key?: string;
/** 产品类型 */
product?: string;
/** 通道 */
channel?: string;
/** 是否沙箱环境 */
sandbox?: boolean;
}

View File

@@ -117,6 +117,11 @@ export const PermCodes = {
VIEW: 'payment:wechat:isv:view',
MANAGE: 'payment:wechat:isv:manage',
},
/** 拉卡拉服务商 menuCode=payment:lakala:isv */
Lakala: {
VIEW: 'payment:lakala:isv:view',
MANAGE: 'payment:lakala:isv:manage',
},
/** 支付主数据 menuCode=payment:platform:* */
Platform: {
Product: {

View File

@@ -1,7 +1,10 @@
{
"title": "Lakala ISV Configuration",
"configTitle": "Lakala ISV Key Configuration",
"manageTitle": "Lakala ISV Management",
"basicConfig": "Basic Configuration",
"keyConfig": "Key Configuration",
"advancedConfig": "Advanced Configuration",
"enable": "Enable",
"lklAppId": "Lakala Application ID",
"lklAppIdPlaceholder": "Please enter Lakala application ID",
@@ -10,9 +13,15 @@
"privateKey": "Private Key Certificate",
"publicKey": "Public Key Certificate",
"sm4Key": "SM4 Key",
"sm4KeyPlaceholder": "Please enter SM4 key",
"sm4KeyPlaceholder": "Please enter SM4 key (for onboarding sensitive fields, optional)",
"uploadPublicKey": "Upload Public Key Certificate",
"uploadPrivateKey": "Upload Private Key Certificate",
"uploadKey": "Upload Private Key",
"sandbox": "Sandbox Environment",
"sandboxOn": "Sandbox",
"sandboxOff": "Production",
"cardMchKey": "ISV Key",
"cardMchKeyDesc": "Configure Lakala ISV application ID, private/public key certificates",
"validation": {
"enable": "Please select whether to enable",
"lklAppId": "Please enter Lakala application ID",

View File

@@ -1,7 +1,10 @@
{
"title": "拉卡拉服务商配置",
"configTitle": "拉卡拉服务商密钥配置",
"manageTitle": "拉卡拉服务商管理",
"basicConfig": "基础配置",
"keyConfig": "密钥配置",
"advancedConfig": "高级配置",
"enable": "是否启用",
"lklAppId": "拉卡拉应用编号",
"lklAppIdPlaceholder": "请输入拉卡拉应用编号",
@@ -10,9 +13,15 @@
"privateKey": "私钥证书",
"publicKey": "公钥证书",
"sm4Key": "SM4密钥",
"sm4KeyPlaceholder": "请输入SM4密钥",
"sm4KeyPlaceholder": "请输入SM4密钥(进件敏感字段加密用, 选填)",
"uploadPublicKey": "上传公钥证书",
"uploadPrivateKey": "上传私钥证书",
"uploadKey": "上传私钥",
"sandbox": "沙箱环境",
"sandboxOn": "沙箱",
"sandboxOff": "生产",
"cardMchKey": "服务商密钥",
"cardMchKeyDesc": "配置拉卡拉服务商应用编号、私钥公钥证书",
"validation": {
"enable": "请选择是否启用",
"lklAppId": "请输入拉卡拉应用编号",

View File

@@ -5,11 +5,16 @@
import { IconifyIcon } from '@vben-core/icons';
import { LakalaPayConfigApi, type LakalaProductConfig } from '#/api/payment/channel/lakala/pay-config.api';
import { type IsvProductPayConfigResult } from '#/api/payment/config/pay-product-config.api';
import {
type LakalaIsvKeyConfig,
LakalaPayConfigApi,
} from '#/api/payment/channel/lakala/pay-config.api';
import { PermCodes } from '#/constants/perm-codes';
import { ProductEnum } from '#/enums/payment/productEnum';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
import { readFileAsText } from '#/utils/file';
import { usePermission } from '#/hooks/usePermission';
import { readFileAsBase64 } from '#/utils/file';
defineOptions({ name: 'LakalaIsvConfigEdit' });
@@ -17,33 +22,27 @@
(e: 'saved'): void;
}>();
const { labelCol, wrapperCol, confirmLoading, visible, showable, handleCancel, diffForm } = useFormEdit();
const { labelCol, wrapperCol, confirmLoading, visible, handleCancel, diffForm } = useFormEdit();
const { message } = useMessage();
const { hasPermission } = usePermission();
const isvNo = ref('');
const productCode = ref('');
const sandboxEnv = ref(false);
const formRef = ref();
const form = ref<LakalaProductConfig>({} as LakalaProductConfig);
const form = ref<LakalaIsvKeyConfig>({} as LakalaIsvKeyConfig);
let rawForm: Record<string, any> = {};
const drawerTitle = computed(() => {
return $t('payment.channel.lakalaIsv.title');
});
const canEdit = computed(() => hasPermission(PermCodes.Payment.Lakala.MANAGE));
const rules = computed(() => ({
enable: [{ required: true, message: $t('payment.channel.lakalaIsv.validation.enable') }],
const drawerTitle = $t('payment.channel.lakalaIsv.configTitle');
const rules = {
lklAppId: [{ required: true, message: $t('payment.channel.lakalaIsv.validation.lklAppId') }],
mchSerialNo: [{ required: true, message: $t('payment.channel.lakalaIsv.validation.mchSerialNo') }],
privateKey: [{ required: true, message: $t('payment.channel.lakalaIsv.validation.privateKey') }],
publicKey: [{ required: true, message: $t('payment.channel.lakalaIsv.validation.publicKey') }],
}));
};
function init(no: string, _configInfo: IsvProductPayConfigResult, sandbox: boolean) {
isvNo.value = no;
productCode.value = _configInfo.product || '';
sandboxEnv.value = sandbox;
/** 打开抽屉并加载拉卡拉服务商密钥配置(平台为唯一服务商,密钥全局唯一) */
function init() {
visible.value = true;
resetForm();
loadConfig();
@@ -51,16 +50,13 @@
function loadConfig() {
confirmLoading.value = true;
LakalaPayConfigApi.findConfig(isvNo.value, productCode.value, sandboxEnv.value)
LakalaPayConfigApi.findConfig(ProductEnum.LAKALA_PAY)
.then(({ data }) => {
rawForm = { ...data };
form.value = {
isvNo: isvNo.value,
product: productCode.value,
channel: 'lakala',
sandbox: sandboxEnv.value,
product: ProductEnum.LAKALA_PAY,
...data,
} as LakalaProductConfig;
} as LakalaIsvKeyConfig;
})
.finally(() => {
confirmLoading.value = false;
@@ -72,11 +68,17 @@
confirmLoading.value = true;
LakalaPayConfigApi.saveConfig({
...form.value,
...diffForm(rawForm, form.value, 'privateKey', 'publicKey', 'sm4Key'),
isvNo: isvNo.value,
product: productCode.value,
channel: 'lakala',
sandbox: sandboxEnv.value,
...diffForm(
rawForm,
form.value,
'lklAppId',
'mchSerialNo',
'privateKey',
'publicKey',
'sm4Key',
'sandbox',
),
product: ProductEnum.LAKALA_PAY,
})
.then(() => {
message.success($t('common.saveSuccess'));
@@ -92,19 +94,13 @@
function handleUpload(info: { file: File }, fieldName: string) {
const file = info.file;
if (!file) return;
readFileAsText(file).then((content) => {
readFileAsBase64(file).then((content) => {
(form.value as Record<string, any>)[fieldName] = content;
message.success($t('components.upload.uploadSuccess', { name: file.name }));
formRef.value?.validateFields(fieldName);
});
}
function truncateContent(content: string, maxLength = 500): string {
if (!content) return '';
if (content.length <= maxLength) return content;
return content.slice(0, Math.max(0, maxLength)) + '...';
}
function resetForm() {
nextTick(() => {
formRef.value?.resetFields();
@@ -121,6 +117,7 @@
size="large"
:styles="{ footer: { textAlign: 'right' } }"
:mask-closable="false"
destroy-on-hidden
@close="handleCancel"
>
<a-spin :spinning="confirmLoading">
@@ -134,19 +131,10 @@
>
<a-divider orientation="left">{{ $t('payment.channel.lakalaIsv.basicConfig') }}</a-divider>
<a-form-item :label="$t('payment.channel.lakalaIsv.enable')" name="enable">
<a-switch
v-model:checked="form.enable"
:disabled="showable"
:checked-children="$t('common.enable')"
:un-checked-children="$t('common.disable')"
/>
</a-form-item>
<a-form-item :label="$t('payment.channel.lakalaIsv.lklAppId')" name="lklAppId">
<a-input
v-model:value="form.lklAppId"
:disabled="showable"
:disabled="!canEdit"
:placeholder="$t('payment.channel.lakalaIsv.lklAppIdPlaceholder')"
/>
</a-form-item>
@@ -154,79 +142,86 @@
<a-form-item :label="$t('payment.channel.lakalaIsv.mchSerialNo')" name="mchSerialNo">
<a-input
v-model:value="form.mchSerialNo"
:disabled="showable"
:disabled="!canEdit"
:placeholder="$t('payment.channel.lakalaIsv.mchSerialNoPlaceholder')"
/>
</a-form-item>
<a-divider orientation="left">{{ $t('payment.channel.lakalaIsv.keyConfig') }}</a-divider>
<a-form-item :label="$t('payment.channel.lakalaIsv.privateKey')" name="privateKey">
<a-upload
v-if="!form.privateKey"
:disabled="!canEdit"
:multiple="false"
:show-upload-list="false"
accept=".pem,.key,.txt"
:before-upload="() => false"
@change="(info: any) => handleUpload(info, 'privateKey')"
>
<a-button :disabled="!canEdit">
<template #icon><IconifyIcon icon="ant-design:upload-outlined" class="text-lg" /></template>
{{ $t('payment.channel.lakalaIsv.uploadKey') }}
</a-button>
</a-upload>
<a-input v-else value="private_key.pem" disabled>
<template #suffix>
<span v-if="canEdit" class="cursor-pointer text-gray-400" @click="form.privateKey = ''">
<IconifyIcon icon="ant-design:close-circle-outlined" class="text-lg" />
</span>
</template>
</a-input>
</a-form-item>
<a-form-item :label="$t('payment.channel.lakalaIsv.publicKey')" name="publicKey">
<a-upload
v-if="!form.publicKey"
:disabled="showable"
:disabled="!canEdit"
:multiple="false"
:show-upload-list="false"
accept=".cer"
accept=".pem,.cer,.crt,.txt"
:before-upload="() => false"
@change="(info: any) => handleUpload(info, 'publicKey')"
>
<a-button :disabled="showable">
<a-button :disabled="!canEdit">
<template #icon><IconifyIcon icon="ant-design:upload-outlined" class="text-lg" /></template>
{{ $t('payment.channel.lakalaIsv.uploadPublicKey') }}
</a-button>
</a-upload>
<a-tooltip v-else :title="truncateContent(form.publicKey)" placement="top" :mouse-enter-delay="0.3">
<a-input value="publicKey.cer" disabled>
<template #suffix>
<span v-if="!showable" class="cursor-pointer text-gray-400" @click="form.publicKey = ''">
<IconifyIcon icon="ant-design:close-circle-outlined" class="text-lg" />
</span>
</template>
</a-input>
</a-tooltip>
<a-input v-else value="lakala_public_key.pem" disabled>
<template #suffix>
<span v-if="canEdit" class="cursor-pointer text-gray-400" @click="form.publicKey = ''">
<IconifyIcon icon="ant-design:close-circle-outlined" class="text-lg" />
</span>
</template>
</a-input>
</a-form-item>
<a-form-item :label="$t('payment.channel.lakalaIsv.privateKey')" name="privateKey">
<a-upload
v-if="!form.privateKey"
:disabled="showable"
:multiple="false"
:show-upload-list="false"
accept=".pem"
:before-upload="() => false"
@change="(info: any) => handleUpload(info, 'privateKey')"
>
<a-button :disabled="showable">
<template #icon><IconifyIcon icon="ant-design:upload-outlined" class="text-lg" /></template>
{{ $t('payment.channel.lakalaIsv.uploadPrivateKey') }}
</a-button>
</a-upload>
<a-tooltip v-else :title="truncateContent(form.privateKey)" placement="top" :mouse-enter-delay="0.3">
<a-input value="private_key.pem" disabled>
<template #suffix>
<span v-if="!showable" class="cursor-pointer text-gray-400" @click="form.privateKey = ''">
<IconifyIcon icon="ant-design:close-circle-outlined" class="text-lg" />
</span>
</template>
</a-input>
</a-tooltip>
</a-form-item>
<a-divider orientation="left">{{ $t('payment.channel.lakalaIsv.advancedConfig') }}</a-divider>
<a-form-item :label="$t('payment.channel.lakalaIsv.sm4Key')" name="sm4Key">
<a-input
v-model:value="form.sm4Key"
:disabled="showable"
:disabled="!canEdit"
:placeholder="$t('payment.channel.lakalaIsv.sm4KeyPlaceholder')"
/>
</a-form-item>
<a-form-item :label="$t('payment.channel.lakalaIsv.sandbox')" name="sandbox">
<a-switch
v-model:checked="form.sandbox"
:disabled="!canEdit"
:checked-children="$t('payment.channel.lakalaIsv.sandboxOn')"
:un-checked-children="$t('payment.channel.lakalaIsv.sandboxOff')"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-space>
<a-button @click="handleCancel">{{ $t('common.cancel') }}</a-button>
<a-button v-if="!showable" type="primary" :loading="confirmLoading" @click="handleOk">
<a-button v-if="canEdit" type="primary" :loading="confirmLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>

View File

@@ -0,0 +1,114 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import LakalaIsvConfigEdit from '#/views/payment/channel/lakala/config/LakalaIsvConfigEdit.vue';
defineOptions({ name: 'LakalaManage' });
const mchKeyEditRef = ref<InstanceType<typeof LakalaIsvConfigEdit> | null>(null);
/**
* 功能卡片配置
* 拉卡拉为聚合服务商模式, 密钥全局唯一, 不区分应用/能力绑定, 仅一个密钥配置入口
*/
const functionCards = computed(() => [
{
group: $t('payment.channel.lakalaIsv.manageTitle'),
color: 'blue',
cards: [
{
key: 'mchKey',
title: $t('payment.channel.lakalaIsv.cardMchKey'),
icon: 'ant-design:key-outlined',
description: $t('payment.channel.lakalaIsv.cardMchKeyDesc'),
},
],
},
]);
function getIconBgClass(color: string) {
const map: Record<string, string> = {
green: 'bg-success/10 text-success',
blue: 'bg-primary/10 text-primary',
};
return map[color] || 'bg-muted text-muted-foreground';
}
function getGroupColorClass(color: string) {
const map: Record<string, string> = {
green: 'bg-emerald-500',
blue: 'bg-blue-500',
};
return map[color] || 'bg-gray-500';
}
/** 初始化(由分发页调用, 平台为唯一服务商, 无需服务商号) */
function init() {
}
function handleCardClick(card: { key: string }) {
if (card.key === 'mchKey') {
mchKeyEditRef.value?.init();
}
}
defineExpose({ init });
</script>
<template>
<div class="space-y-12 py-4">
<div v-for="group in functionCards" :key="group.group">
<div class="mb-6 flex items-center gap-3 px-2">
<div class="h-6 w-1.5 rounded-full shadow-sm" :class="getGroupColorClass(group.color)"></div>
<span class="text-xl font-extrabold tracking-tight text-foreground">{{ group.group }}</span>
</div>
<div class="card-grid">
<a-card
v-for="card in group.cards"
:key="card.key"
hoverable
class="isv-card group relative overflow-hidden rounded-2xl border-none bg-card shadow-md transition-all duration-300 hover:-translate-y-1.5 hover:shadow-xl"
:styles="{ body: { padding: '24px 20px' } }"
@click="handleCardClick(card)"
>
<div class="flex flex-col items-center text-center">
<div
class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:shadow-md"
:class="getIconBgClass(group.color)"
>
<IconifyIcon :icon="card.icon" class="h-7 w-7" />
</div>
<div
class="mb-1.5 text-base font-bold text-foreground group-hover:text-primary transition-colors duration-300"
>{{ card.title }}</div>
<a-tooltip :title="card.description" placement="bottom">
<div class="line-clamp-1 text-xs leading-relaxed text-muted-foreground">{{ card.description }}</div>
</a-tooltip>
</div>
<div
class="absolute bottom-0 left-0 h-1.5 w-0 transition-all duration-300 group-hover:w-full"
:class="getGroupColorClass(group.color)"
></div>
</a-card>
</div>
</div>
<LakalaIsvConfigEdit ref="mchKeyEditRef" />
</div>
</template>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
gap: 24px;
justify-content: center;
}
.isv-card {
max-height: 200px;
}
</style>

View File

@@ -7,6 +7,7 @@
import { IconifyIcon } from '@vben-core/icons';
import AlipayIsvManage from '#/views/payment/channel/alipay/manage/AlipayIsvManage.vue';
import LakalaManage from '#/views/payment/channel/lakala/manage/LakalaManage.vue';
import WechatIsvManage from '#/views/payment/channel/wechat/manage/WechatIsvManage.vue';
defineOptions({ name: 'ProductDetailDispatch' });
@@ -44,6 +45,10 @@
currentComponent.value = markRaw(WechatIsvManage);
break;
}
case 'lakala_pay': {
currentComponent.value = markRaw(LakalaManage);
break;
}
default: {
currentComponent.value = null;
break;
@@ -62,6 +67,9 @@
if (product.value === 'wechat_isv') {
return $t('payment.constant.product.productName.wechatIsv');
}
if (product.value === 'lakala_pay') {
return $t('payment.product.enum.lakalaPay');
}
if (product.value) {
return $t('payment.constant.product.productName.unknown', { product: product.value });
}