feat(M09-D1): 完成商品目录与库存流水底座

This commit is contained in:
Codex
2026-08-11 03:51:18 +08:00
parent 2de796143f
commit 93af128002
37 changed files with 14683 additions and 58 deletions
+10
View File
@@ -62,6 +62,8 @@ import {
type SystemOperationsRouteOptions
} from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -90,6 +92,8 @@ export interface BuildAppOptions {
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
products?: ProductRouteOptions;
inventory?: InventoryRouteOptions;
}
declare module 'fastify' {
@@ -206,6 +210,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.adminAuth) {
await registerAdminAuthRoutes(app, options.adminAuth);
}
if (options.products) {
await registerProductRoutes(app, options.products);
}
if (options.inventory) {
await registerInventoryRoutes(app, options.inventory);
}
return app;
}
+8 -3
View File
@@ -141,14 +141,19 @@ export class AuthRepository {
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'))
WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId]
);
+6 -1
View File
@@ -37,7 +37,8 @@ export class RbacRepository {
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
@@ -45,11 +46,15 @@ export class RbacRepository {
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`,
+325 -6
View File
@@ -11,6 +11,15 @@ export interface MigrationPlan {
file: string;
checksum: string;
statements: readonly string[];
migrations?: readonly MigrationFilePlan[];
}
export interface MigrationFilePlan {
version: string;
name: string;
file: string;
checksum: string;
statements: readonly string[];
}
export interface MigrationExecutionResult extends MigrationPlan {
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
affectedRows: number;
}
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
const migrationLockExpression =
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [
@@ -55,7 +70,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -92,9 +108,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
],
down: [
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
@@ -223,17 +241,30 @@ export async function loadMigrationPlan(direction: MigrationDirection): Promise<
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
);
const sql = sqlParts.join('\n');
const migrations = relativeFiles.map((file, index) => {
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
if (!match) throw new Error(`Invalid migration file name: ${file}`);
const migrationSql = sqlParts[index];
return {
version: match[1],
name: match[2],
file,
checksum: createHash('sha256').update(migrationSql).digest('hex'),
statements: splitSqlStatements(migrationSql)
};
});
return {
direction,
file: relativeFiles.join(','),
checksum: createHash('sha256').update(sql).digest('hex'),
statements: splitSqlStatements(sql)
statements: splitSqlStatements(sql),
migrations
};
}
export async function executeMigrationPlan(
pool: Pick<MySqlPool, 'query'>,
pool: MigrationPool,
plan: MigrationPlan,
dryRun = false
): Promise<MigrationExecutionResult> {
@@ -241,9 +272,64 @@ export async function executeMigrationPlan(
return { ...plan, executed: false, affectedRows: 0 };
}
if (pool.getConnection) {
const connection = await pool.getConnection();
let lockAcquired = false;
let executionFailed = false;
try {
const [rows] = await connection.query(
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
);
const acquired = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
: 0;
if (acquired !== 1) {
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
}
lockAcquired = true;
return await executeMigrationPlanUnlocked(connection, plan);
} catch (error) {
executionFailed = true;
throw error;
} finally {
let releaseError: unknown;
if (lockAcquired) {
try {
const [rows] = await connection.query(
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
);
const released = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
: 0;
if (released !== 1) {
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
}
} catch (error) {
releaseError = error;
}
}
connection.release();
if (!executionFailed && releaseError) throw releaseError;
}
}
return executeMigrationPlanUnlocked(pool, plan);
}
async function executeMigrationPlanUnlocked(
pool: MigrationQueryExecutor,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
if (plan.direction === 'up' && plan.migrations?.length) {
return executeVersionedUpPlan(pool, plan);
}
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
let affectedRows = 0;
for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement);
const result = await queryMigrationStatement(pool, statement);
if (plan.direction === 'verify') {
const minimumRows = [
10, 26, 1,
@@ -279,7 +365,8 @@ export async function executeMigrationPlan(
1, 2, 3, 1,
1, 2, 3, 1,
3, 7, 3, 1,
2, 8, 4, 1
2, 8, 4, 1,
9, 63, 18, 28, 15, 2, 4, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -297,3 +384,235 @@ export async function executeMigrationPlan(
return { ...plan, executed: true, affectedRows };
}
interface AppliedMigrationRow extends Record<string, unknown> {
version?: unknown;
name?: unknown;
checksum?: unknown;
}
async function executeVersionedUpPlan(
pool: Pick<MySqlPool, 'query'>,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
const migrations = plan.migrations ?? [];
const appliedRows = await readAppliedMigrations(pool);
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
for (const migration of migrations) {
const row = applied.get(migration.version);
if (!row) continue;
if (String(row.name) !== migration.name) {
throw new Error(
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
);
}
const storedChecksum = String(row.checksum ?? '');
if (storedChecksum && storedChecksum !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
const pending = migrations.filter((migration) => !applied.has(migration.version));
await assertTriggerConfiguration(
pool,
pending.flatMap((migration) => [...migration.statements])
);
let affectedRows = 0;
for (const migration of pending) {
for (const statement of migration.statements) {
const result = await queryMigrationStatement(pool, statement);
if (result && typeof result === 'object' && 'affectedRows' in result) {
const value = Reflect.get(result, 'affectedRows');
if (typeof value === 'number') affectedRows += value;
}
}
const [markerRows] = await pool.query(
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const marker = Array.isArray(markerRows)
? markerRows[0] as Record<string, unknown> | undefined
: undefined;
if (!marker || String(marker.name) !== migration.name) {
throw new Error(
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
);
}
await storeMigrationChecksum(pool, migration);
}
for (const migration of migrations) {
await storeMigrationChecksum(pool, migration);
}
return { ...plan, executed: true, affectedRows };
}
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
try {
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
throw error;
}
}
async function storeMigrationChecksum(
pool: Pick<MySqlPool, 'query'>,
migration: MigrationFilePlan
) {
try {
const [result] = await pool.query(
`UPDATE qipai_schema_migrations
SET checksum = ?
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
[migration.checksum, migration.version, migration.checksum]
);
if (result && typeof result === 'object'
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
const [rows] = await pool.query(
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const stored = Array.isArray(rows)
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
: '';
if (stored && stored !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
throw error;
}
}
async function assertTriggerConfiguration(
pool: Pick<MySqlPool, 'query'>,
statements: readonly string[]
) {
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
const [rows] = await pool.query(
`SELECT @@GLOBAL.log_bin AS logBin,
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
);
const setting = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
if (Number(setting?.logBin ?? 0) === 1
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
throw new Error(
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
);
}
}
async function queryMigrationStatement(
pool: Pick<MySqlPool, 'query'>,
statement: string
) {
try {
const [result] = await pool.query(statement);
return result;
} catch (error) {
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
throw error;
}
}
async function isMissingM09D1DownObject(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (indexTarget) {
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${indexTarget.table}'
AND index_name = '${indexTarget.index}'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
async function isMatchingM09D1ChecksumColumn(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
is_nullable AS isNullable, column_default AS columnDefault,
collation_name AS collationName
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'`
);
const column = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
return column?.dataType === 'char'
&& Number(column.maxLength) === 64
&& column.isNullable === 'NO'
&& String(column.columnDefault ?? '') === ''
&& column.collationName === 'ascii_bin';
}
async function isMatchingM09D1ScopeIndex(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
return false;
}
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (!target) return false;
const [rows] = await pool.query(
`SELECT non_unique AS nonUnique,
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${target.table}'
AND index_name = '${target.index}'
GROUP BY non_unique`
);
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -113,6 +113,8 @@ function adminAccess(access: AccessProfile) {
const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant || access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('inventory.read') ? ['products'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
+250
View File
@@ -0,0 +1,250 @@
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 { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const version = z.number().int().min(1).max(4_294_967_295);
const createVersion = z.number().int().min(0).max(4_294_967_295);
const quantity = z.number().int().min(1).max(1_000_000_000);
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
const reason = z.string().trim().min(1).max(512);
const stockParams = z.object({ skuId: id }).strict();
const ledgerParams = z.object({ inventoryId: id }).strict();
const stockListQuery = z.object({
storeId: id,
page,
pageSize,
skuId: id.optional(),
productId: id.optional(),
search: z.string().trim().max(64).optional()
}).strict();
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
const mutationBase = {
storeId: id,
requestId,
reason,
expectedVersion: version.optional()
};
const policyBody = z.object({
...mutationBase,
expectedVersion: createVersion,
policyType: z.enum(['TRACKED', 'UNLIMITED']),
lowStockThreshold: nonNegativeQuantity
}).strict();
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
const adjustmentBody = z.object({
...mutationBase,
availableDelta: delta,
lossDelta: delta.default(0)
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
const stocktakeBody = z.object({
...mutationBase,
expectedVersion: version,
availableQuantity: nonNegativeQuantity,
lossQuantity: nonNegativeQuantity
}).strict();
const lossBody = z.object({ ...mutationBase, quantity }).strict();
export interface InventoryRouteOptions {
service: Pick<InventoryService,
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
| 'stocktake' | 'recordLoss'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerInventoryRoutes(
app: FastifyInstance,
options: InventoryRouteOptions
) {
app.get('/admin-api/inventory/stocks', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const query = stockListQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listStocks(actor, query.data),
traceId: request.traceId
}));
});
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const params = ledgerParams.safeParse(request.params);
const query = ledgerQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listLedger(actor, {
inventoryId: params.data.inventoryId,
...query.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = policyBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.configurePolicy(actor, {
skuId: params.data.skuId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = inboundBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = adjustmentBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = stocktakeBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = lossBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
}
async function requireInventoryActor(
request: FastifyRequest,
reply: FastifyReply,
options: InventoryRouteOptions,
write: boolean
): Promise<ManagementActor | null> {
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
);
const manager = access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const allowed = manager || (write
? access.capabilities.includes('inventory.adjust')
: access.capabilities.includes('inventory.read')
|| access.capabilities.includes('inventory.adjust'));
if (!allowed) {
reply.status(403).send({
code: 'INVENTORY_OPERATION_FORBIDDEN',
message: 'Inventory 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'] ?? ''
};
}
async function inventoryResponse(
reply: FastifyReply,
traceId: string,
work: () => Promise<unknown>
) {
try {
return await work();
} catch (error) {
if (!(error instanceof InventoryError)) throw error;
const status = inventoryErrorStatus(error.code);
return reply.status(status).send({
code: error.code,
message: 'The inventory operation is not allowed.',
traceId
});
}
}
function inventoryErrorStatus(code: string) {
if (code.endsWith('_FORBIDDEN')) return 403;
if (code.endsWith('_NOT_FOUND')) return 404;
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
return 400;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_INVENTORY_REQUEST',
message: 'The inventory request is invalid.',
traceId
});
}
+487
View File
@@ -0,0 +1,487 @@
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 {
ProductCatalogError,
type ProductCatalogRepository
} from '../products/product-catalog-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const version = z.coerce.number().int().min(0).max(0xffffffff);
const code = z.string().trim().min(1).max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
const storeParamsSchema = z.object({ storeId: id }).strict();
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
const productParamsSchema = z.object({ productId: id }).strict();
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
const includeInactiveQuerySchema = z.object({
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
}).strict();
const categoryFields = {
parentId: id.nullable().optional(),
categoryCode: code,
name: z.string().trim().min(1).max(128),
description: z.string().trim().max(1024).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createCategorySchema = z.object(categoryFields).strict();
const updateCategorySchema = z.object({
...categoryFields,
expectedVersion: version.min(1)
}).strict();
const productFields = {
productCode: code,
name: z.string().trim().min(1).max(128),
unitName: z.string().trim().min(1).max(32),
description: z.string().trim().max(10000).default(''),
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
images: z.array(z.string().url().max(512)).max(20).default([]),
deliveryEnabled: z.boolean().default(true),
storageEnabled: z.boolean().default(false),
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createProductSchema = z.object(productFields).strict();
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
const productListQuerySchema = z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
search: z.string().trim().min(1).max(128).optional()
}).strict();
const attributesSchema = z.record(
z.string().trim().min(1).max(64),
z.string().trim().max(128)
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
const skuFields = {
skuCode: code,
name: z.string().trim().min(1).max(128),
attributes: attributesSchema.default({}),
barcode: z.string().trim().max(64).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
salePriceCents: z.number().int().min(0).max(100000000),
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
costPriceCents: z.number().int().min(0).max(100000000).default(0),
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
};
const createSkuSchema = z.object(skuFields).strict();
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
const listingSchema = z.object({
expectedVersion: version,
categoryId: id,
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
salesStartAt: z.coerce.date().nullable().optional(),
salesEndAt: z.coerce.date().nullable().optional(),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|| value.salesEndAt > value.salesStartAt, {
message: 'salesEndAt must be later than salesStartAt'
});
const hourSchema = z.object({
weekday: z.number().int().min(1).max(7),
slotNo: z.number().int().min(1).max(8),
openMinute: z.number().int().min(0).max(1439),
closeMinute: z.number().int().min(0).max(1439),
crossesMidnight: z.boolean()
}).strict();
const settingsSchema = z.object({
expectedVersion: version,
salesStatus: z.enum(['OPEN', 'CLOSED']),
manualPaused: z.boolean(),
manualPausedUntil: z.coerce.date().nullable().optional(),
manualPauseReason: z.string().trim().max(512).default(''),
hours: z.array(hourSchema).max(56).default([])
}).strict().superRefine((value, context) => {
if (value.manualPaused && value.manualPauseReason.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['manualPauseReason'],
message: 'manualPauseReason is required while manually paused'
});
}
});
export interface ProductRouteOptions {
repository: Pick<ProductCatalogRepository,
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
| 'listListings' | 'putListing' | 'archiveListing'
| 'getStoreSettings' | 'putStoreSettings'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
for (const path of [
'/admin-api/stores/:storeId/product-categories',
'/app-api/management/stores/:storeId/product-categories'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listCategories(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = createCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const body = updateCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateCategory(
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveCategory(
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const query = productListQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listProducts(actor, query.data),
traceId: request.traceId
}));
});
app.post('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const body = createProductSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createProduct(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = updateProductSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateProduct(
actor, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveProduct(
actor, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const params = productParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSkus(
actor, params.data.productId, query.data.includeInactive
),
traceId: request.traceId
}));
});
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = createSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createSku(actor, params.data.productId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const body = updateSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateSku(
actor, params.data.productId, params.data.skuId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveSku(
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-listings',
'/app-api/management/stores/:storeId/product-listings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listListings(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const body = listingSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putListing(
actor, params.data.storeId, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveListing(
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-sales-settings',
'/app-api/management/stores/:storeId/product-sales-settings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getStoreSettings(actor, params.data.storeId),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = settingsSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putStoreSettings(
actor, params.data.storeId, expectedVersion, input
),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: ProductRouteOptions,
mode: 'read' | 'write',
scope: 'store' | 'global-catalog' = 'store'
): Promise<ManagementActor | null> {
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
);
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
const allowed = access.capabilities.includes(capability)
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|| access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!allowed) {
reply.status(403).send({
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
message: 'Product catalog permission is required.',
traceId: request.traceId
});
return null;
}
if (scope === 'global-catalog') {
const tenantManager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
&& access.storeIds.length > 0
&& (access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('product.catalog.write'));
if (!tenantManager && !authorizedStoreAdmin) {
reply.status(403).send({
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
message: 'Tenant management or an authorized store administrator 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'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductCatalogError)) throw error;
const code = error.code;
const status = code.endsWith('_FORBIDDEN') ? 403
: code.endsWith('_NOT_FOUND') ? 404
: code.includes('INVALID') ? 400
: 409;
return reply.status(status).send({
code,
message: 'The product catalog request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
});
}
+16
View File
@@ -44,6 +44,8 @@ import { BusinessStatisticsRepository } from './operations/business-statistics-r
import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
import { InventoryService } from './inventory/inventory-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -54,6 +56,8 @@ const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
const cleaningTaskRepository = new CleaningTaskRepository(pool);
const productCatalogRepository = new ProductCatalogRepository(pool);
const inventoryService = new InventoryService(pool);
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
@@ -241,6 +245,18 @@ const app = await buildApp({
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds
},
products: {
repository: productCatalogRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
inventory: {
service: inventoryService,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {