feat(M08-D): 补加盟申请与跟进运营

This commit is contained in:
Codex
2026-08-10 13:17:19 +08:00
parent e0e17159a7
commit aa4a6bd014
28 changed files with 894 additions and 13 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -56,6 +56,7 @@ import {
registerBusinessStatisticsRoutes,
type BusinessStatisticsRouteOptions
} from './routes/business-statistics.js';
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -81,6 +82,7 @@ export interface BuildAppOptions {
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
}
declare module 'fastify' {
@@ -188,6 +190,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.businessStatistics) {
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
}
if (options.franchise) {
await registerFranchiseRoutes(app, options.franchise);
}
return app;
}
+7 -3
View File
@@ -51,7 +51,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql'
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -84,9 +85,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql'
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
],
down: [
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
@@ -263,7 +266,8 @@ export async function executeMigrationPlan(
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1,
1, 1, 1
1, 1, 1,
1, 2, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
export interface FranchiseApplicationInput {
tenantId: string;
submittedUserId?: string | null;
city: string;
contactName: string;
contactPhone: string;
message: string;
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
clientRequestId: string;
traceId: string;
ip: string;
userAgent: string;
}
interface ApplicationRow extends RowDataPacket {
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
contactPhone: string; message: string; source: string; status: FranchiseStatus;
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
}
interface FollowUpRow extends RowDataPacket {
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
note: string; nextFollowUpAt: Date | null; createdAt: Date;
}
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
export class FranchiseError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class FranchiseRepository {
constructor(private readonly pool: MySqlPool) {}
async submitApplication(input: FranchiseApplicationInput) {
return this.transaction(async (connection) => {
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_applications
(tenant_id, application_no, client_request_id, city, contact_name,
contact_phone, message, source, submitted_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
);
const applicationId = String(result.insertId);
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, applicationId]
);
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
if (created) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
input.userAgent.slice(0, 255), input.source, input.city]
);
}
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
});
}
async listApplications(input: {
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
assigneeUserId?: string; search?: string;
}) {
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
const params: Array<string> = [input.tenantId];
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
if (input.search) {
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
const term = `%${input.search}%`; params.push(term, term, term, term);
}
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
params
);
const offset = (input.page - 1) * input.pageSize;
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
LIMIT ${input.pageSize} OFFSET ${offset}`,
params
);
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
}
async getApplication(tenantId: string, applicationId: string) {
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
[tenantId, applicationId]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
const [followUps] = await this.pool.execute<FollowUpRow[]>(
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
f.created_at AS createdAt
FROM qipai_franchise_follow_ups f
INNER JOIN qipai_users u ON u.id = f.actor_user_id
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
[tenantId, applicationId]
);
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
}
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
return this.transaction(async (connection) => {
await this.lockApplication(connection, tenantId, applicationId);
if (assigneeUserId) {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL`,
[tenantId, assigneeUserId]
);
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
}
await connection.execute(
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
[assigneeUserId, tenantId, applicationId]
);
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
{ assigneeUserId });
return { applicationId, assigneeUserId };
});
}
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
}) {
return this.transaction(async (connection) => {
const current = await this.lockApplication(connection, tenantId, applicationId);
const nextStatus = input.status ?? current.status;
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
}
await connection.execute(
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
WHERE tenant_id = ? AND id = ?`,
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
);
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
return { applicationId, followUpId, status: nextStatus };
});
}
private applicationSelect() {
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
a.created_at AS createdAt, a.updated_at AS updatedAt
FROM qipai_franchise_applications a
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
}
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
const [rows] = await connection.execute<StatusRow[]>(
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
[tenantId, id]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
return rows[0];
}
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_follow_ups
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
);
return String(result.insertId);
}
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
action: string, resourceId: string, metadata: object) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; }
finally { connection.release(); }
}
}
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
};
function normalizeApplication(row: ApplicationRow) {
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
}
+148
View File
@@ -0,0 +1,148 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { FranchiseError, type FranchiseRepository } from '../franchise/franchise-repository.js';
import { AmbiguousAppTenantError } from '../tenancy/platform-config-repository.js';
import type { PlatformConfigResolver } from './platform-bootstrap.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const status = z.enum(['NEW', 'CONTACTED', 'QUALIFIED', 'REJECTED', 'CONVERTED']);
const contextHeaders = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': id.optional()
});
const applicationSchema = z.object({
city: z.string().trim().min(1).max(64),
contactName: z.string().trim().min(1).max(64),
contactPhone: z.string().trim().regex(/^\+?[0-9 -]{6,32}$/),
message: z.string().trim().max(1000).default(''),
clientRequestId: z.string().trim().min(8).max(128)
});
const listSchema = z.object({
tenantId: id.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20), status: status.optional(),
assigneeUserId: id.optional(), search: z.string().trim().max(64).optional()
});
const tenantQuery = z.object({ tenantId: id.optional() });
const assignmentSchema = z.object({ tenantId: id.optional(), assigneeUserId: id.nullable() });
const followUpSchema = z.object({
tenantId: id.optional(), followUpType: z.enum(['CALL', 'WECHAT', 'MEETING', 'NOTE']),
note: z.string().trim().min(1).max(1000), nextFollowUpAt: z.coerce.date().nullable().optional(),
status: status.optional()
});
export interface FranchiseRouteOptions {
repository: Pick<FranchiseRepository, 'submitApplication' | 'listApplications'
| 'getApplication' | 'assignApplication' | 'addFollowUp'>;
platformConfig: PlatformConfigResolver;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerFranchiseRoutes(app: FastifyInstance, options: FranchiseRouteOptions) {
app.post('/app-api/franchise-applications', async (request, reply) => {
const headers = contextHeaders.safeParse(request.headers);
const body = applicationSchema.safeParse(request.body);
if (!headers.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
let bootstrap;
try {
bootstrap = await options.platformConfig.resolveBootstrap(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
} catch (error) {
if (error instanceof AmbiguousAppTenantError) throw new FranchiseError('FRANCHISE_TENANT_SELECTION_REQUIRED');
throw error;
}
if (!bootstrap) throw new FranchiseError('FRANCHISE_TENANT_NOT_FOUND');
const auth = request.headers.authorization
? await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret)
: null;
if (request.headers.authorization && !auth) throw new FranchiseError('FRANCHISE_SESSION_INVALID');
const submittedUserId = auth?.session.tenantId === bootstrap.tenantId ? auth.session.user.id : null;
return reply.status(201).send({ code: 0, data: await options.repository.submitApplication({
tenantId: bootstrap.tenantId, submittedUserId, ...body.data, source: 'MINIAPP',
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
}), traceId: request.traceId });
});
});
app.get('/admin-api/franchise-applications', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const query = listSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.listApplications({ ...query.data, tenantId: targetTenantId }),
traceId: request.traceId }));
});
app.get('/admin-api/franchise-applications/:id', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const query = tenantQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.getApplication(targetTenantId, params.data.id), traceId: request.traceId }));
});
app.patch('/admin-api/franchise-applications/:id/assignee', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = assignmentSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.assignApplication(actor, targetTenantId, params.data.id,
body.data.assigneeUserId), traceId: request.traceId }));
});
app.post('/admin-api/franchise-applications/:id/follow-ups', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = followUpSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({ code: 0,
data: await options.repository.addFollowUp(actor, targetTenantId, params.data.id, body.data),
traceId: request.traceId }));
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: FranchiseRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'FRANCHISE_MANAGEMENT_FORBIDDEN', message: 'Franchise management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { return await work(); } catch (error) {
if (!(error instanceof FranchiseError)) throw error;
const statusCode = error.code === 'FRANCHISE_SESSION_INVALID' ? 401
: error.code.endsWith('_NOT_FOUND') ? 404
: error.code.includes('FORBIDDEN') ? 403
: error.code.endsWith('_SELECTION_REQUIRED') ? 409 : 400;
return reply.status(statusCode).send({ code: error.code, message: 'The franchise request is invalid or not allowed.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_FRANCHISE_REQUEST', message: 'The franchise request is invalid.', traceId }); }
function forbidden(reply: FastifyReply, traceId: string) { return reply.status(403).send({ code: 'FRANCHISE_TENANT_FORBIDDEN', message: 'The tenant is outside the allowed scope.', traceId }); }
+10 -1
View File
@@ -41,11 +41,13 @@ import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { FranchiseRepository } from './franchise/franchise-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool);
const platformConfigRepository = new PlatformConfigRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
@@ -69,7 +71,7 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
const app = await buildApp({
config,
mqtt,
platformConfigRepository: new PlatformConfigRepository(pool),
platformConfigRepository,
platformManagement: {
repository: new PlatformAdminRepository(pool),
authRepository,
@@ -216,6 +218,13 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
franchise: {
repository: new FranchiseRepository(pool),
platformConfig: platformConfigRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-franchise-management-secret-32';
const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
let submitted;
let listInput;
let assignment;
let followUp;
const repository = {
async submitApplication(input) { submitted = input; return { applicationId: '101', applicationNo: 'FR101', idempotent: false }; },
async listApplications(input) { listInput = input; return { items: [], total: 0, page: input.page, pageSize: input.pageSize }; },
async getApplication(tenantId, id) { return { application: { id, tenantId }, followUps: [] }; },
async assignApplication(_actor, tenantId, id, assigneeUserId) { assignment = { tenantId, id, assigneeUserId }; return { applicationId: id, assigneeUserId }; },
async addFollowUp(_actor, tenantId, id, input) { followUp = { tenantId, id, input }; return { applicationId: id, followUpId: '301', status: input.status || 'NEW' }; }
};
const app = await buildApp({ franchise: {
repository,
platformConfig: { async resolveBootstrap(appId, tenantId) { return appId === 'wx-franchise' ? { tenantId: tenantId || '7' } : null; } },
authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } },
accessControl: { async getAccessProfile() { return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; } },
jwtSecret: secret
} });
const created = await app.inject({ method: 'POST', url: '/app-api/franchise-applications', headers: { 'x-wechat-appid': 'wx-franchise' }, payload: { city: '上海市', contactName: '张先生', contactPhone: '13800138000', message: '计划开店', clientRequestId: 'franchise-request-001' } });
assert.equal(created.statusCode, 201);
assert.equal(submitted.tenantId, '7');
assert.equal(submitted.contactPhone, '13800138000');
assert.equal('contactPhone' in (submitted.audit || {}), false);
const auth = { authorization: `Bearer ${token}` };
const listed = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?status=NEW&page=2&pageSize=10', headers: auth });
assert.equal(listed.statusCode, 200);
assert.deepEqual({ tenantId: listInput.tenantId, status: listInput.status, page: listInput.page }, { tenantId: '7', status: 'NEW', page: 2 });
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?tenantId=8', headers: auth });
assert.equal(crossTenant.statusCode, 403);
const assigned = await app.inject({ method: 'PATCH', url: '/admin-api/franchise-applications/101/assignee', headers: auth, payload: { assigneeUserId: '22' } });
assert.equal(assigned.statusCode, 200);
assert.deepEqual(assignment, { tenantId: '7', id: '101', assigneeUserId: '22' });
const followed = await app.inject({ method: 'POST', url: '/admin-api/franchise-applications/101/follow-ups', headers: auth, payload: { followUpType: 'CALL', note: '已电话沟通', status: 'CONTACTED' } });
assert.equal(followed.statusCode, 201);
assert.equal(followUp.input.status, 'CONTACTED');
await app.close();
console.log('PASS: M08-D franchise submission, tenant scope, assignment and follow-up routes are present.');
+10
View File
@@ -102,6 +102,9 @@ const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff
const contentAssetScopeUpSql = read('database/migrations/2026081002_m08d_content_asset_scope.up.sql');
const contentAssetScopeDownSql = read('database/migrations/2026081002_m08d_content_asset_scope.down.sql');
const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_content_asset_scope.verify.sql');
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -473,4 +476,11 @@ assert.match(contentAssetScopeDownSql, /SET a\.image_asset_id = duplicate_group\
assert.match(contentAssetScopeDownSql, /uq_qipai_media_tenant_checksum/);
assert.match(contentAssetScopeVerifySql, /generation_expression/);
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_applications/);
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
assert.match(franchiseUpSql, /'2026081003'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
+3 -2
View File
@@ -41,14 +41,15 @@ assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql$/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081002_m08d_content_asset_scope\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql$/);
const calls = [];
const fakePool = {
@@ -16,6 +16,7 @@ import { RbacRepository } from '../dist/auth/rbac-repository.js';
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import { FranchiseRepository, FranchiseError } from '../dist/franchise/franchise-repository.js';
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
@@ -60,6 +61,8 @@ const expectedTables = [
'qipai_device_status_snapshots',
'qipai_devices',
'qipai_direct_bookings',
'qipai_franchise_applications',
'qipai_franchise_follow_ups',
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
@@ -129,13 +132,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002']
'2026062220', '2026081002', '2026081003']
);
return rows;
}
@@ -1748,6 +1751,50 @@ async function assertContentManagement(pool, context) {
);
}
async function assertFranchiseManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
);
const adminId = String(adminRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const actor = { tenantId: context.tenantId, userId: adminId, access,
traceId: 'm08d-franchise-live', ip: '127.0.0.1', userAgent: 'M08-D franchise live test' };
const repository = new FranchiseRepository(pool);
const input = { tenantId: context.tenantId, city: '上海市', contactName: '测试联系人',
contactPhone: '13800138000', message: '计划开设两家门店', source: 'MINIAPP',
clientRequestId: 'm08d-franchise-idempotent', traceId: actor.traceId,
ip: actor.ip, userAgent: actor.userAgent };
const first = await repository.submitApplication(input);
const duplicate = await repository.submitApplication(input);
assert.equal(first.idempotent, false);
assert.equal(duplicate.idempotent, true);
assert.equal(duplicate.applicationId, first.applicationId);
assert.equal((await repository.listApplications({ tenantId: context.tenantId,
page: 1, pageSize: 20, status: 'NEW' })).total, 1);
await assert.rejects(
() => repository.assignApplication(actor, context.tenantId, first.applicationId, '999999999'),
(error) => error instanceof FranchiseError && error.code === 'FRANCHISE_ASSIGNEE_INVALID'
);
await repository.assignApplication(actor, context.tenantId, first.applicationId, adminId);
await repository.addFollowUp(actor, context.tenantId, first.applicationId, {
followUpType: 'CALL', note: '已完成首次电话沟通', status: 'CONTACTED', nextFollowUpAt: null
});
await assert.rejects(() => repository.addFollowUp(actor, context.tenantId, first.applicationId, {
followUpType: 'NOTE', note: '跳过资格确认', status: 'CONVERTED'
}), (error) => error instanceof FranchiseError && error.code === 'FRANCHISE_STATUS_TRANSITION_INVALID');
const detail = await repository.getApplication(context.tenantId, first.applicationId);
assert.equal(detail.application.status, 'CONTACTED');
assert.equal(detail.followUps.length, 2);
const [auditRows] = await pool.query(
`SELECT CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
WHERE tenant_id = ? AND resource_type = 'FRANCHISE_APPLICATION'`, [context.tenantId]
);
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
}
async function assertDeviceTopology(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -2026,7 +2073,8 @@ try {
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2034,6 +2082,7 @@ try {
await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext);
await assertFranchiseManagement(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
@@ -2077,7 +2126,8 @@ try {
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');