@@ -274,6 +288,7 @@ import {
Send,
Sparkles,
Store,
+ TicketCheck,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
@@ -285,6 +300,7 @@ import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import OrdersPanel from './components/OrdersPanel.vue';
import PaymentsPanel from './components/PaymentsPanel.vue';
+import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -321,7 +337,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
-const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'cleaning'>('overview');
+const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -335,6 +351,7 @@ const activeModuleMeta = computed(() => ({
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
payments: { stage: 'M08-D', title: '支付、退款与分账' },
+ thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
diff --git a/admin/src/api.ts b/admin/src/api.ts
index e3cd227..c56f736 100644
--- a/admin/src/api.ts
+++ b/admin/src/api.ts
@@ -25,6 +25,10 @@ import type {
RoomInput,
RoomOperationalStatus,
StoreInput,
+ ThirdPartyMode,
+ ThirdPartyProvider,
+ ThirdPartyRecords,
+ ThirdPartySetup,
UserStatus,
WechatTransferPreflight
} from './types';
@@ -229,6 +233,57 @@ export function requestWechatReconciliation(session: ApiSession, input: {
);
}
+export function listThirdPartyRecords(session: ApiSession, input: {
+ provider?: ThirdPartyProvider; status?: string; storeId?: string;
+}) {
+ const params = new URLSearchParams();
+ if (input.provider) params.set('provider', input.provider);
+ if (input.status) params.set('status', input.status);
+ if (input.storeId) params.set('storeId', input.storeId);
+ const query = params.toString();
+ return request
(session, `/third-party/records${query ? `?${query}` : ''}`);
+}
+
+export function getThirdPartySetup(session: ApiSession, provider?: ThirdPartyProvider) {
+ const query = provider ? `?${new URLSearchParams({ provider })}` : '';
+ return request(session, `/third-party/setup${query}`);
+}
+
+export function saveThirdPartyConfig(session: ApiSession, input: {
+ provider: ThirdPartyProvider; storeId: string | null; mode: ThirdPartyMode;
+ enabled: boolean; credentialRef: string; settings: Record;
+}) {
+ return request<{ configId: string; created: boolean }>(session, '/third-party/config', {
+ method: 'PUT', body: JSON.stringify(input)
+ });
+}
+
+export function saveThirdPartyMapping(session: ApiSession, input: {
+ provider: ThirdPartyProvider; resourceType: 'STORE' | 'ROOM';
+ externalRef: string; localResourceId: string;
+}) {
+ return request<{ mapped: boolean }>(session, '/third-party/mappings', {
+ method: 'PUT', body: JSON.stringify(input)
+ });
+}
+
+export function redeemGroupVoucher(session: ApiSession, input: {
+ provider: ThirdPartyProvider; voucherCode: string; orderId: string; clientRequestId: string;
+}) {
+ return request<{ redemptionId: string; status: string; voucherMasked: string }>(
+ session, '/group-vouchers/redeem', { method: 'POST', body: JSON.stringify(input) }
+ );
+}
+
+export function redeemGroupVoucherManually(session: ApiSession, input: {
+ provider: ThirdPartyProvider; voucherCode: string; orderId: string;
+ amountCents: number; note: string; clientRequestId: string;
+}) {
+ return request<{ redemptionId: string; status: string; voucherMasked: string }>(
+ session, '/group-vouchers/redeem-manual', { method: 'POST', body: JSON.stringify(input) }
+ );
+}
+
export function getBusinessStatistics(
session: ApiSession,
input: { storeId: string; from: string; to: string }
diff --git a/admin/src/components/ThirdPartyPanel.vue b/admin/src/components/ThirdPartyPanel.vue
new file mode 100644
index 0000000..a304708
--- /dev/null
+++ b/admin/src/components/ThirdPartyPanel.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+ 直订记录{{ records.bookings.length }}
+ 待映射直订{{ pendingMappingCount }}
+ 核销记录{{ records.redemptions.length }}
+ 失败 / 待处理{{ attentionCount }}
+
+
+
+
+
+
+
+
+ {{ row.externalBookingNo }}{{ providerLabel(row.provider) }} · #{{ row.id }}
+ {{ row.storeId ? storeName(row.storeId) : '待映射门店' }}{{ row.roomId ? roomName(row.roomId) : '待映射房间' }}
+ {{ formatDate(row.startsAt) }}至 {{ formatDate(row.endsAt) }}
+ {{ money(row.amountCents) }}
+ {{ row.status }}
+ {{ row.orderId ? `#${row.orderId}` : '-' }}{{ row.failureCode || formatDate(row.createdAt) }}
+
+
+
+
+ 券码只显示脱敏结果,真实券码不会进入列表或日志。验券
+
+ {{ row.voucherMasked }}{{ providerLabel(row.provider) }} · {{ row.mode }}
+ 订单 #{{ row.orderId }}{{ storeName(row.storeId) }}
+ {{ row.status }}
+ {{ row.actorId ? `#${row.actorId}` : '-' }}
+ {{ formatDate(row.completedAt || row.createdAt) }}{{ row.failureCode || '-' }}
+
+
+
+
+ 只登记环境凭据引用;敏感设置键由服务端过滤,不在读取接口返回。配置平台
+
+ {{ providerLabel(row.provider) }}
+ {{ row.storeId ? storeName(row.storeId) : '租户默认' }}
+
+ {{ row.credentialConfigured ? '已配置引用' : '未配置' }}{{ row.credentialRef || '-' }}
+ {{ JSON.stringify(row.settings) }}
+ {{ row.enabled ? '是' : '否' }}
+
+
+
+
+ 外部门店与房间必须映射到同一租户下的真实资源。新增映射
+
+ {{ providerLabel(row.provider) }}
+
+
+ {{ mappingResourceName(row.resourceType, row.localResourceId) }}
+
+
+
+
+
+
+ 平台 API/Mock人工确认
+ 取消确认验券
+
+
+
+
+ 取消保存配置
+
+
+
+ 门店房间
+ 取消保存映射
+
+
+
+
+
diff --git a/admin/src/styles.css b/admin/src/styles.css
index 0c01041..462aa6f 100644
--- a/admin/src/styles.css
+++ b/admin/src/styles.css
@@ -1487,6 +1487,53 @@ textarea {
line-height: 1.6;
}
+.third-party-page {
+ display: grid;
+ gap: 14px;
+}
+
+.third-party-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.third-party-metrics span {
+ display: grid;
+ gap: 6px;
+ padding: 14px;
+ background: #fff;
+ border: 1px solid #d8e0ea;
+ border-radius: 8px;
+}
+
+.third-party-metrics small {
+ color: #69788c;
+}
+
+.third-party-metrics strong {
+ color: #172033;
+ font-size: 22px;
+}
+
+.third-party-toolbar h3 {
+ margin: 0;
+}
+
+.third-party-tabs {
+ padding: 0 14px 14px;
+}
+
+.settings-preview {
+ display: block;
+ max-width: 320px;
+ overflow: hidden;
+ color: #526177;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.el-button {
border-radius: 8px;
}
@@ -1591,6 +1638,7 @@ textarea {
}
.payment-metrics,
+ .third-party-metrics,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1700,6 +1748,7 @@ textarea {
}
.payment-metrics,
+ .third-party-metrics,
.payment-tools-grid {
grid-template-columns: 1fr;
}
diff --git a/admin/src/types.ts b/admin/src/types.ts
index 69691a5..b0a5cd9 100644
--- a/admin/src/types.ts
+++ b/admin/src/types.ts
@@ -206,6 +206,68 @@ export interface ProfitSharingSnapshot {
shares: ProfitShareRecord[];
}
+export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
+export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
+
+export interface DirectBookingRecord {
+ id: string;
+ provider: ThirdPartyProvider;
+ externalBookingNo: string;
+ storeId: string | null;
+ roomId: string | null;
+ startsAt: string;
+ endsAt: string;
+ amountCents: number;
+ status: string;
+ orderId: string | null;
+ failureCode: string;
+ createdAt: string;
+}
+
+export interface GroupRedemptionRecord {
+ id: string;
+ provider: ThirdPartyProvider;
+ voucherMasked: string;
+ orderId: string;
+ storeId: string;
+ mode: ThirdPartyMode;
+ actorId: string | null;
+ status: string;
+ failureCode: string;
+ createdAt: string;
+ completedAt: string | null;
+}
+
+export interface ThirdPartyRecords {
+ bookings: DirectBookingRecord[];
+ redemptions: GroupRedemptionRecord[];
+}
+
+export interface ThirdPartyConfigSummary {
+ id: string;
+ provider: ThirdPartyProvider;
+ storeId: string | null;
+ mode: ThirdPartyMode;
+ enabled: boolean;
+ credentialRef: string;
+ credentialConfigured: boolean;
+ settings: Record;
+ updatedAt: string;
+}
+
+export interface ThirdPartyMappingSummary {
+ id: string;
+ provider: ThirdPartyProvider;
+ resourceType: 'STORE' | 'ROOM';
+ externalRef: string;
+ localResourceId: string;
+}
+
+export interface ThirdPartySetup {
+ configs: ThirdPartyConfigSummary[];
+ mappings: ThirdPartyMappingSummary[];
+}
+
export interface BusinessStatistics {
storeId: string;
from: string;
diff --git a/backend/src/routes/third-party.ts b/backend/src/routes/third-party.ts
index 8096094..cb8a3b2 100644
--- a/backend/src/routes/third-party.ts
+++ b/backend/src/routes/third-party.ts
@@ -39,6 +39,7 @@ const recordsQuery = z.object({
status: z.string().min(1).max(32).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
+const setupQuery = z.object({ provider: providerSchema.optional() });
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
@@ -83,56 +84,49 @@ export async function registerThirdPartyRoutes(
}));
});
- app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
- const auth = await authenticate(request.headers.authorization, options, true);
- const body = manualSchema.safeParse(request.body);
- if (!auth) return unauthorized(reply, request.traceId);
- if (!body.success) return invalid(reply, request.traceId);
- return handle(reply, request.traceId, async () => ({
- code: 0,
- data: await options.service.redeemVoucherManually({
- tenantId: auth.tenantId,
- actorId: auth.userId,
- access: auth.access!,
- ...body.data
- }),
- traceId: request.traceId
- }));
- });
+ for (const path of [
+ '/admin-api/group-vouchers/redeem',
+ '/app-api/management/group-vouchers/redeem'
+ ]) {
+ app.post(path, async (request, reply) => {
+ const auth = await authenticate(request.headers.authorization, options, true);
+ const body = redeemSchema.safeParse(request.body);
+ if (!auth) return unauthorized(reply, request.traceId);
+ if (!body.success) return invalid(reply, request.traceId);
+ return handle(reply, request.traceId, async () => ({
+ code: 0,
+ data: await options.service.redeemVoucherAsManager({
+ tenantId: auth.tenantId,
+ actorId: auth.userId,
+ access: auth.access!,
+ ...body.data
+ }),
+ traceId: request.traceId
+ }));
+ });
+ }
- app.post('/app-api/management/group-vouchers/redeem', async (request, reply) => {
- const auth = await authenticate(request.headers.authorization, options, true);
- const body = redeemSchema.safeParse(request.body);
- if (!auth) return unauthorized(reply, request.traceId);
- if (!body.success) return invalid(reply, request.traceId);
- return handle(reply, request.traceId, async () => ({
- code: 0,
- data: await options.service.redeemVoucherAsManager({
- tenantId: auth.tenantId,
- actorId: auth.userId,
- access: auth.access!,
- ...body.data
- }),
- traceId: request.traceId
- }));
- });
-
- app.post('/app-api/management/group-vouchers/redeem-manual', async (request, reply) => {
- const auth = await authenticate(request.headers.authorization, options, true);
- const body = manualSchema.safeParse(request.body);
- if (!auth) return unauthorized(reply, request.traceId);
- if (!body.success) return invalid(reply, request.traceId);
- return handle(reply, request.traceId, async () => ({
- code: 0,
- data: await options.service.redeemVoucherManually({
- tenantId: auth.tenantId,
- actorId: auth.userId,
- access: auth.access!,
- ...body.data
- }),
- traceId: request.traceId
- }));
- });
+ for (const path of [
+ '/admin-api/group-vouchers/redeem-manual',
+ '/app-api/management/group-vouchers/redeem-manual'
+ ]) {
+ app.post(path, async (request, reply) => {
+ const auth = await authenticate(request.headers.authorization, options, true);
+ const body = manualSchema.safeParse(request.body);
+ if (!auth) return unauthorized(reply, request.traceId);
+ if (!body.success) return invalid(reply, request.traceId);
+ return handle(reply, request.traceId, async () => ({
+ code: 0,
+ data: await options.service.redeemVoucherManually({
+ tenantId: auth.tenantId,
+ actorId: auth.userId,
+ access: auth.access!,
+ ...body.data
+ }),
+ traceId: request.traceId
+ }));
+ });
+ }
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
@@ -192,6 +186,22 @@ export async function registerThirdPartyRoutes(
}));
});
+ app.get('/admin-api/third-party/setup', async (request, reply) => {
+ const auth = await authenticate(request.headers.authorization, options, true);
+ const query = setupQuery.safeParse(request.query);
+ if (!auth) return unauthorized(reply, request.traceId);
+ if (!query.success) return invalid(reply, request.traceId);
+ return handle(reply, request.traceId, async () => ({
+ code: 0,
+ data: await options.service.listSetup({
+ tenantId: auth.tenantId,
+ access: auth.access!,
+ provider: query.data.provider as ThirdPartyProvider | undefined
+ }),
+ traceId: request.traceId
+ }));
+ });
+
app.get('/app-api/management/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
diff --git a/backend/src/third-party/third-party-service.ts b/backend/src/third-party/third-party-service.ts
index 9551a06..0a72fc7 100644
--- a/backend/src/third-party/third-party-service.ts
+++ b/backend/src/third-party/third-party-service.ts
@@ -345,6 +345,55 @@ export class ThirdPartyService {
return { bookings, redemptions };
}
+ async listSetup(input: {
+ tenantId: string;
+ access: AccessProfile;
+ provider?: ThirdPartyProvider;
+ }) {
+ if (!input.access.capabilities.includes('tenant.manage')
+ && !input.access.roles.includes('PLATFORM_ADMIN')) {
+ throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
+ }
+ const params = input.provider ? [input.tenantId, input.provider] : [input.tenantId];
+ const providerFilter = input.provider ? 'AND provider = ?' : '';
+ const [configs] = await this.pool.execute(
+ `SELECT id, provider, store_id AS storeId, mode, enabled,
+ credential_ref AS credentialRef, settings, updated_at AS updatedAt
+ FROM qipai_third_party_configs
+ WHERE tenant_id = ? ${providerFilter}
+ ORDER BY provider, store_id IS NULL DESC, store_id, id`,
+ params
+ );
+ const [mappings] = await this.pool.execute(
+ `SELECT id, provider, resource_type AS resourceType,
+ external_ref AS externalRef, local_resource_id AS localResourceId
+ FROM qipai_third_party_mappings
+ WHERE tenant_id = ? ${providerFilter}
+ ORDER BY provider, resource_type, id`,
+ params
+ );
+ return {
+ configs: configs.map((row) => ({
+ id: String(row.id),
+ provider: row.provider,
+ storeId: row.storeId === null ? null : String(row.storeId),
+ mode: row.mode,
+ enabled: Boolean(row.enabled),
+ credentialRef: String(row.credentialRef || ''),
+ credentialConfigured: Boolean(row.credentialRef),
+ settings: sanitizeSettings(row.settings),
+ updatedAt: row.updatedAt
+ })),
+ mappings: mappings.map((row) => ({
+ id: String(row.id),
+ provider: row.provider,
+ resourceType: row.resourceType,
+ externalRef: row.externalRef,
+ localResourceId: String(row.localResourceId)
+ }))
+ };
+ }
+
async saveConfig(input: {
tenantId: string;
actorId: string;
@@ -724,6 +773,17 @@ function sanitizePayload(value: Record) {
return copy;
}
+function sanitizeSettings(value: unknown): Record {
+ let source: unknown = value;
+ if (typeof source === 'string') {
+ try { source = JSON.parse(source); } catch { return {}; }
+ }
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
+ return Object.fromEntries(Object.entries(source as Record)
+ .filter(([key]) => !/(secret|token|password|private|api.?key|credential)/i.test(key))
+ .map(([key, item]) => [key, item]));
+}
+
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
diff --git a/backend/tests/third-party.test.mjs b/backend/tests/third-party.test.mjs
index 653efdd..8c68433 100644
--- a/backend/tests/third-party.test.mjs
+++ b/backend/tests/third-party.test.mjs
@@ -66,6 +66,7 @@ let managerRedeemInput;
let manualRedeemInput;
let recordsInput;
let notifyInput;
+let setupInput;
const routeApp = await buildApp({
thirdParty: {
jwtSecret: secret,
@@ -116,6 +117,13 @@ const routeApp = await buildApp({
recordsInput = input;
return { bookings: [], redemptions: [] };
},
+ async listSetup(input) {
+ setupInput = input;
+ return {
+ configs: [{ id: '81', provider: 'MEITUAN', credentialConfigured: true, settings: {} }],
+ mappings: [{ id: '91', provider: 'MEITUAN', resourceType: 'STORE' }]
+ };
+ },
async saveConfig() {
return { configId: '81', created: true };
},
@@ -153,6 +161,18 @@ assert.equal(managerRedeemed.statusCode, 200);
assert.equal(managerRedeemInput.actorId, '21');
assert.equal(managerRedeemInput.provider, 'DOUYIN');
+const adminRedeemed = await routeApp.inject({
+ method: 'POST',
+ url: '/admin-api/group-vouchers/redeem',
+ headers: { authorization: `Bearer ${token}` },
+ payload: {
+ provider: 'MEITUAN', voucherCode: '1122334455', orderId: '31',
+ clientRequestId: 'admin-redeem-request-001'
+ }
+});
+assert.equal(adminRedeemed.statusCode, 200);
+assert.equal(managerRedeemInput.provider, 'MEITUAN');
+
const manualRedeemed = await routeApp.inject({
method: 'POST',
url: '/app-api/management/group-vouchers/redeem-manual',
@@ -177,6 +197,15 @@ assert.equal(recordsInput.storeId, '11');
assert.equal(recordsInput.provider, 'MEITUAN');
assert.equal(recordsInput.status, 'SUCCEEDED');
+const setup = await routeApp.inject({
+ method: 'GET',
+ url: '/admin-api/third-party/setup?provider=MEITUAN',
+ headers: { authorization: `Bearer ${token}` }
+});
+assert.equal(setup.statusCode, 200);
+assert.equal(setup.json().data.configs[0].credentialConfigured, true);
+assert.equal(setupInput.provider, 'MEITUAN');
+
const bookingPayload = {
eventId: 'event-001',
externalBookingNo: 'booking-001',
diff --git a/scripts/check-admin-m08-d.mjs b/scripts/check-admin-m08-d.mjs
index 7073189..7aedf80 100644
--- a/scripts/check-admin-m08-d.mjs
+++ b/scripts/check-admin-m08-d.mjs
@@ -13,10 +13,12 @@ for (const pattern of [
"activeModule = 'stores'",
"activeModule = 'orders'",
"activeModule = 'payments'",
+ "activeModule = 'thirdParty'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
'PaymentsPanel',
+ 'ThirdPartyPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -120,6 +122,24 @@ for (const pattern of [
assert.match(payments, new RegExp(pattern));
}
+const thirdParty = read('admin/src/components/ThirdPartyPanel.vue');
+for (const pattern of [
+ 'listThirdPartyRecords',
+ 'getThirdPartySetup',
+ 'redeemGroupVoucher',
+ 'redeemGroupVoucherManually',
+ 'saveThirdPartyConfig',
+ 'saveThirdPartyMapping',
+ 'voucherCode',
+ 'PENDING_MAPPING'
+]) {
+ assert.match(thirdParty, new RegExp(pattern));
+}
+
+const thirdPartyRoutes = read('backend/src/routes/third-party.ts');
+assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/);
+assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/);
+
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -151,6 +171,8 @@ for (const pattern of [
'.history-card',
'.payment-metrics',
'.payment-tools-grid',
+ '.third-party-metrics',
+ '.settings-preview',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {