feat(M09-D3): 完成商品寄存与安全取出闭环
This commit is contained in:
@@ -68,6 +68,10 @@ import {
|
||||
registerProductOrderRoutes,
|
||||
type ProductOrderRouteOptions
|
||||
} from './routes/product-orders.js';
|
||||
import {
|
||||
registerProductStorageRoutes,
|
||||
type ProductStorageRouteOptions
|
||||
} from './routes/product-storages.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -99,6 +103,7 @@ export interface BuildAppOptions {
|
||||
products?: ProductRouteOptions;
|
||||
inventory?: InventoryRouteOptions;
|
||||
productOrders?: ProductOrderRouteOptions;
|
||||
productStorages?: ProductStorageRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -224,6 +229,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.productOrders) {
|
||||
await registerProductOrderRoutes(app, options.productOrders);
|
||||
}
|
||||
if (options.productStorages) {
|
||||
await registerProductStorageRoutes(app, options.productStorages);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -143,20 +143,23 @@ export class AuthRepository {
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
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',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
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',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -39,7 +39,8 @@ export class RbacRepository {
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
@@ -50,6 +51,7 @@ export class RbacRepository {
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
@@ -58,6 +60,7 @@ export class RbacRepository {
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -72,7 +72,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -111,9 +112,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081109_m09d3_product_storage.down.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||
@@ -370,7 +373,8 @@ async function executeMigrationPlanUnlocked(
|
||||
3, 7, 3, 1,
|
||||
2, 8, 4, 1,
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1,
|
||||
7, 54, 22, 18, 34, 6, 2, 1, 1
|
||||
7, 54, 22, 18, 34, 6, 2, 1, 1,
|
||||
4, 30, 17, 14, 16, 4, 2, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
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 {
|
||||
ProductStorageError,
|
||||
productStorageStatuses,
|
||||
type ProductStorageActor,
|
||||
type ProductStorageService
|
||||
} from '../products/product-storage-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,128}$/);
|
||||
const expiresAt = z.coerce.date();
|
||||
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 storageParams = z.object({ storageId: id }).strict();
|
||||
const orderItem = z.object({ orderItemId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
|
||||
const skuItem = z.object({ skuId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
|
||||
const storageItem = z.object({
|
||||
storageItemId: id, quantity: z.number().int().min(1).max(1_000_000_000)
|
||||
}).strict();
|
||||
const fromOrderSchema = z.object({
|
||||
requestId, sourceOrderId: id, expiresAt,
|
||||
items: z.array(orderItem).min(1).max(100).optional()
|
||||
}).strict();
|
||||
const managementCreateSchema = z.discriminatedUnion('sourceType', [
|
||||
z.object({
|
||||
sourceType: z.literal('ORDER'), requestId, sourceOrderId: id, expiresAt,
|
||||
items: z.array(orderItem).min(1).max(100).optional()
|
||||
}).strict(),
|
||||
z.object({
|
||||
sourceType: z.literal('MANUAL'), requestId, storeId: id, memberId: id,
|
||||
expiresAt, items: z.array(skuItem).min(1).max(100)
|
||||
}).strict()
|
||||
]);
|
||||
const customerListSchema = z.object({
|
||||
storeId: id.optional(), status: z.enum(productStorageStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const managementListSchema = z.object({
|
||||
storeId: id, memberId: id.optional(),
|
||||
status: z.enum(productStorageStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const retrieveSchema = z.object({
|
||||
requestId,
|
||||
claimCredential: z.string().regex(/^[A-Za-z0-9_-]{20,128}$/),
|
||||
items: z.array(storageItem).min(1).max(100)
|
||||
}).strict();
|
||||
const rotateSchema = z.object({ requestId }).strict();
|
||||
const cancelSchema = z.object({
|
||||
requestId, reason: z.string().trim().min(1).max(512)
|
||||
}).strict();
|
||||
const expireSchema = z.object({
|
||||
storeId: id, limit: z.number().int().min(1).max(100).default(100)
|
||||
}).strict();
|
||||
|
||||
export interface ProductStorageRouteOptions {
|
||||
service: Pick<ProductStorageService,
|
||||
'createFromOrder' | 'createManual' | 'listForCustomer' | 'getForCustomer'
|
||||
| 'listForManagement' | 'getForManagement' | 'retrieve'
|
||||
| 'rotateCredential' | 'cancel' | 'expireDueForManagement'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerProductStorageRoutes(
|
||||
app: FastifyInstance, options: ProductStorageRouteOptions
|
||||
) {
|
||||
app.post('/app-api/product-storages', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const body = fromOrderSchema.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.service.createFromOrder(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-storages', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const query = customerListSchema.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.service.listForCustomer(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-storages/:storageId', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = storageParams.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.service.getForCustomer(actor, params.data.storageId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-storages/:storageId/retrieve', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = storageParams.safeParse(request.params);
|
||||
const body = retrieveSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.retrieve(actor, params.data.storageId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-storages/:storageId/credential/rotate', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = storageParams.safeParse(request.params);
|
||||
const body = rotateSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.rotateCredential(actor, params.data.storageId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-storages/:storageId/cancel', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = storageParams.safeParse(request.params);
|
||||
const body = cancelSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.cancel(actor, params.data.storageId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const prefix of ['/admin-api', '/app-api/management']) {
|
||||
app.post(`${prefix}/product-storages`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const body = managementCreateSchema.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: body.data.sourceType === 'ORDER'
|
||||
? await options.service.createFromOrder(actor, body.data)
|
||||
: await options.service.createManual(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/product-storages`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const query = managementListSchema.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.service.listForManagement(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/product-storages/:storageId`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const params = storageParams.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.service.getForManagement(actor, params.data.storageId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const action of ['retrieve', 'credential/rotate', 'cancel'] as const) {
|
||||
app.post(`${prefix}/product-storages/:storageId/${action}`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = storageParams.safeParse(request.params);
|
||||
const schema = action === 'retrieve' ? retrieveSchema
|
||||
: action === 'cancel' ? cancelSchema : rotateSchema;
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => {
|
||||
const data = action === 'retrieve'
|
||||
? await options.service.retrieve(
|
||||
actor, params.data.storageId, body.data as z.infer<typeof retrieveSchema>
|
||||
) : action === 'cancel'
|
||||
? await options.service.cancel(
|
||||
actor, params.data.storageId, body.data as z.infer<typeof cancelSchema>
|
||||
) : await options.service.rotateCredential(
|
||||
actor, params.data.storageId, body.data as z.infer<typeof rotateSchema>
|
||||
);
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.post(`${prefix}/product-storages/expire-due`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const body = expireSchema.safeParse(request.body ?? {});
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0, data: await options.service.expireDueForManagement(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: ProductStorageRouteOptions,
|
||||
management: boolean,
|
||||
write = false
|
||||
): Promise<ProductStorageActor | 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
|
||||
);
|
||||
if (management) {
|
||||
const privileged = access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const capability = write ? 'goods.storage.manage' : 'goods.storage.read';
|
||||
if (!privileged && !access.capabilities.includes(capability)
|
||||
&& !access.capabilities.includes('goods.storage.manage')) {
|
||||
reply.status(403).send({
|
||||
code: 'PRODUCT_STORAGE_OPERATION_FORBIDDEN',
|
||||
message: 'Product storage permission is required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||
source: management ? 'MANAGEMENT' : 'CUSTOMER', 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 ProductStorageError)) throw error;
|
||||
const status = error.code.includes('NOT_FOUND') ? 404
|
||||
: error.code.includes('FORBIDDEN') ? 403
|
||||
: error.code.includes('CONFLICT') || error.code.includes('ALREADY')
|
||||
|| error.code.includes('NOT_ACTIVE') ? 409 : 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The requested product storage operation is not available.', traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'PRODUCT_STORAGE_INPUT_INVALID',
|
||||
message: 'The product storage input is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
import { ProductOrderService } from './products/product-order-service.js';
|
||||
import { ProductStorageService } from './products/product-storage-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -60,6 +61,7 @@ const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const productOrderService = new ProductOrderService(pool, inventoryService);
|
||||
const productStorageService = new ProductStorageService(pool);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
@@ -266,6 +268,12 @@ const app = await buildApp({
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||
},
|
||||
productStorages: {
|
||||
service: productStorageService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
Reference in New Issue
Block a user