feat(M09-D3): 完成商品寄存与安全取出闭环
This commit is contained in:
@@ -17,6 +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",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.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/admin-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/system-operations.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 && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -138,6 +138,15 @@ const productOrderPaymentDownSql = read(
|
||||
const productOrderPaymentVerifySql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
);
|
||||
const productStorageUpSql = read(
|
||||
'database/migrations/2026081109_m09d3_product_storage.up.sql'
|
||||
);
|
||||
const productStorageDownSql = read(
|
||||
'database/migrations/2026081109_m09d3_product_storage.down.sql'
|
||||
);
|
||||
const productStorageVerifySql = read(
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -684,4 +693,38 @@ assert.match(productOrderPaymentUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TE
|
||||
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
||||
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D2 migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_product_storage_records',
|
||||
'qipai_product_storage_items',
|
||||
'qipai_product_storage_events',
|
||||
'qipai_product_storage_event_items'
|
||||
]) {
|
||||
assert.match(productStorageUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(productStorageDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(productStorageVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.doesNotMatch(productStorageUpSql, /\bDECIMAL\b/i);
|
||||
for (const state of [
|
||||
'STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'
|
||||
]) assert.match(productStorageUpSql, new RegExp(`'${state}'`));
|
||||
assert.match(productStorageUpSql, /claim_credential_hash CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productStorageUpSql, /request_fingerprint CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productStorageUpSql, /remaining_quantity <= total_quantity/);
|
||||
assert.match(productStorageUpSql, /uq_qipai_product_storage_source_order/);
|
||||
assert.match(productStorageUpSql, /uq_qipai_product_storage_event_request/);
|
||||
assert.match(productStorageUpSql, /uq_qipai_product_storage_event_version/);
|
||||
assert.match(productStorageUpSql, /qipai_product_storage_events_no_update/);
|
||||
assert.match(productStorageUpSql, /qipai_product_storage_events_no_delete/);
|
||||
assert.match(productStorageUpSql, /qipai_product_storage_event_items_no_update/);
|
||||
assert.match(productStorageUpSql, /qipai_product_storage_event_items_no_delete/);
|
||||
for (const permission of ['goods.storage.read', 'goods.storage.manage']) {
|
||||
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||
assert.match(productStorageUpSql, pattern);
|
||||
assert.match(productStorageDownSql, pattern);
|
||||
assert.match(productStorageVerifySql, pattern);
|
||||
}
|
||||
assert.match(productStorageUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||
assert.match(productStorageVerifySql, /fully_granted_goods_storage_roles/);
|
||||
assert.match(productStorageVerifySql, /'2026081109'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D3 migration contracts are present.');
|
||||
|
||||
@@ -47,7 +47,8 @@ assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/);
|
||||
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -64,12 +65,21 @@ assert.match(
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql$/
|
||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql/
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081109_m09d3_product_storage\.verify\.sql$/
|
||||
);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081109_m09d3_product_storage\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
|
||||
< downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
|
||||
@@ -57,6 +57,9 @@ import {
|
||||
import {
|
||||
ProductOrderError, ProductOrderService
|
||||
} from '../dist/products/product-order-service.js';
|
||||
import {
|
||||
ProductStorageError, ProductStorageService, productStorageCredentialDigest
|
||||
} from '../dist/products/product-storage-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -118,6 +121,10 @@ const expectedTables = [
|
||||
'qipai_product_refund_events',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_storage_event_items',
|
||||
'qipai_product_storage_events',
|
||||
'qipai_product_storage_items',
|
||||
'qipai_product_storage_records',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_store_listings',
|
||||
'qipai_product_store_settings',
|
||||
@@ -174,15 +181,26 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql] = await Promise.all([
|
||||
const productStorageMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081109_m09d3_product_storage'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql, productStorageDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productStorageMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m09d3-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(productStorageDownSql)
|
||||
});
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
@@ -264,14 +282,14 @@ 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', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006', '2026081107', '2026081108']
|
||||
'2026081006', '2026081107', '2026081108', '2026081109']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1959,7 +1977,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081108');
|
||||
assert.equal(overview.latestMigration.version, '2026081109');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -4311,6 +4329,299 @@ async function assertProductOrderPaymentInventory(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProductStorageLifecycle(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.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [customerRows] = 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.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'CUSTOMER'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [fixtureRows] = await pool.query(
|
||||
`SELECT l.store_id AS storeId, s.id AS skuId
|
||||
FROM qipai_product_skus s
|
||||
INNER JOIN qipai_products p
|
||||
ON p.tenant_id = s.tenant_id AND p.id = s.product_id
|
||||
INNER JOIN qipai_product_store_listings l
|
||||
ON l.tenant_id = p.tenant_id AND l.product_id = p.id
|
||||
WHERE p.tenant_id = ? AND p.product_code = 'M09D2-ORDER-DRINK'
|
||||
AND p.storage_enabled = 1 AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
|
||||
AND s.sku_code = 'M09D2-ORDER-DRINK-500'
|
||||
AND s.status = 'ACTIVE' AND s.deleted_at IS NULL
|
||||
AND l.status = 'ACTIVE' AND l.deleted_at IS NULL LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(adminRows[0] && customerRows[0] && fixtureRows[0],
|
||||
'M09-D3 requires the M09-D2 admin, customer and storage-enabled SKU fixtures.');
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(fixtureRows[0].storeId);
|
||||
const skuId = String(fixtureRows[0].skuId);
|
||||
const rbac = new RbacRepository(pool);
|
||||
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||
for (const capability of ['goods.storage.read', 'goods.storage.manage']) {
|
||||
assert.ok(adminAccess.capabilities.includes(capability),
|
||||
`missing M09-D3 capability ${capability}`);
|
||||
}
|
||||
const adminActor = {
|
||||
tenantId: context.tenantId, userId: adminId, access: adminAccess,
|
||||
source: 'MANAGEMENT', traceId: 'm09d3-live-admin', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D3 live MySQL admin test'
|
||||
};
|
||||
const customerActor = {
|
||||
tenantId: context.tenantId, userId: customerId,
|
||||
access: await rbac.getAccessProfile(context.tenantId, customerId),
|
||||
source: 'CUSTOMER', traceId: 'm09d3-live-customer', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D3 live MySQL customer test'
|
||||
};
|
||||
const inventory = new InventoryService(pool);
|
||||
const orders = new ProductOrderService(pool, inventory, {
|
||||
now: () => new Date('2026-08-10T17:00:00.000Z'), paymentHoldMinutes: 15
|
||||
});
|
||||
let credentialSequence = 0;
|
||||
const storages = new ProductStorageService(pool, {
|
||||
now: () => new Date('2026-08-10T18:00:00.000Z'),
|
||||
credentialFactory: () => `m09d3_claim_${String(++credentialSequence).padStart(40, '0')}`
|
||||
});
|
||||
|
||||
const sourceOrder = await orders.create(customerActor, {
|
||||
storeId, requestId: 'm09d3-source-order', fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null, note: 'M09-D3 storage source',
|
||||
items: [{ skuId, quantity: 3, note: 'store after purchase' }]
|
||||
});
|
||||
const payment = await orders.createPaymentForCustomer(customerActor, sourceOrder.id, {
|
||||
requestId: 'm09d3-source-payment', provider: 'TEST'
|
||||
});
|
||||
await orders.completeTestPaymentForCustomer(customerActor, payment.id, {
|
||||
callbackId: 'm09d3-source-payment-callback', amountCents: 2100
|
||||
});
|
||||
await orders.managementAction(adminActor, sourceOrder.id, {
|
||||
requestId: 'm09d3-source-accept', action: 'ACCEPT', reason: ''
|
||||
});
|
||||
await orders.managementAction(adminActor, sourceOrder.id, {
|
||||
requestId: 'm09d3-source-ready', action: 'MARK_READY', reason: ''
|
||||
});
|
||||
const completedOrder = await orders.managementAction(adminActor, sourceOrder.id, {
|
||||
requestId: 'm09d3-source-complete', action: 'COMPLETE', reason: ''
|
||||
});
|
||||
assert.equal(completedOrder.status, 'COMPLETED');
|
||||
|
||||
const createInput = {
|
||||
requestId: 'm09d3-store-from-order', sourceOrderId: sourceOrder.id,
|
||||
expiresAt: new Date('2026-08-12T18:00:00.000Z')
|
||||
};
|
||||
const stored = await storages.createFromOrder(customerActor, createInput);
|
||||
assert.equal(stored.status, 'STORED');
|
||||
assert.equal(stored.totalQuantity, 3);
|
||||
assert.equal(stored.remainingQuantity, 3);
|
||||
assert.match(stored.claimCredential, /^m09d3_claim_/);
|
||||
assert.equal(stored.movements.length, 0);
|
||||
const firstCredential = stored.claimCredential;
|
||||
const storageItemId = String(stored.items[0].id);
|
||||
const [credentialRows] = await pool.query(
|
||||
`SELECT claim_credential_hash AS credentialHash
|
||||
FROM qipai_product_storage_records WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, stored.id]
|
||||
);
|
||||
assert.equal(credentialRows[0].credentialHash,
|
||||
productStorageCredentialDigest(firstCredential));
|
||||
assert.notEqual(credentialRows[0].credentialHash, firstCredential,
|
||||
'only a credential digest may be persisted');
|
||||
const replayedCreate = await storages.createFromOrder(customerActor, createInput);
|
||||
assert.equal(replayedCreate.idempotent, true);
|
||||
assert.equal(replayedCreate.claimCredential, null,
|
||||
'an idempotent replay must never reveal the original raw credential');
|
||||
await assert.rejects(
|
||||
() => storages.createFromOrder(customerActor, {
|
||||
...createInput, expiresAt: new Date('2026-08-13T18:00:00.000Z')
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storages.createFromOrder(customerActor, {
|
||||
...createInput, requestId: 'm09d3-store-source-again'
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_SOURCE_ORDER_ALREADY_STORED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storages.getForCustomer({
|
||||
...customerActor, tenantId: String(BigInt(context.tenantId) + 999n)
|
||||
}, stored.id),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_NOT_FOUND'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storages.getForCustomer({ ...customerActor, userId: '999999999999999999' }, stored.id),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_NOT_FOUND'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-over-quantity', claimCredential: firstCredential,
|
||||
items: [{ storageItemId, quantity: 4 }]
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_QUANTITY_EXCEEDED'
|
||||
);
|
||||
|
||||
const partial = await storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-retrieve-partial', claimCredential: firstCredential,
|
||||
items: [{ storageItemId, quantity: 1 }]
|
||||
});
|
||||
assert.equal(partial.status, 'PARTIALLY_RETRIEVED');
|
||||
assert.equal(partial.remainingQuantity, 2);
|
||||
assert.match(partial.nextClaimCredential, /^m09d3_claim_/);
|
||||
assert.equal(partial.movements.length, 1);
|
||||
assert.equal(Number(partial.movements[0].quantity), 1);
|
||||
assert.equal(Number(partial.movements[0].remainingAfter), 2);
|
||||
const secondCredential = partial.nextClaimCredential;
|
||||
const partialReplay = await storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-retrieve-partial', claimCredential: firstCredential,
|
||||
items: [{ storageItemId, quantity: 1 }]
|
||||
});
|
||||
assert.equal(partialReplay.idempotent, true);
|
||||
assert.equal(partialReplay.nextClaimCredential, null);
|
||||
await assert.rejects(
|
||||
() => storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-old-credential-rejected', claimCredential: firstCredential,
|
||||
items: [{ storageItemId, quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||
);
|
||||
const retrievalRace = await Promise.allSettled([
|
||||
storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-retrieve-race-a', claimCredential: secondCredential,
|
||||
items: [{ storageItemId, quantity: 2 }]
|
||||
}),
|
||||
storages.retrieve(customerActor, stored.id, {
|
||||
requestId: 'm09d3-retrieve-race-b', claimCredential: secondCredential,
|
||||
items: [{ storageItemId, quantity: 2 }]
|
||||
})
|
||||
]);
|
||||
assert.equal(retrievalRace.filter((result) => result.status === 'fulfilled').length, 1,
|
||||
'only one concurrent full retrieval may succeed');
|
||||
const rejectedRetrieval = retrievalRace.find((result) => result.status === 'rejected');
|
||||
assert.ok(rejectedRetrieval?.reason instanceof ProductStorageError);
|
||||
assert.ok(['PRODUCT_STORAGE_NOT_ACTIVE', 'PRODUCT_STORAGE_CREDENTIAL_INVALID']
|
||||
.includes(rejectedRetrieval.reason.code));
|
||||
const retrieved = await storages.getForCustomer(customerActor, stored.id);
|
||||
assert.equal(retrieved.status, 'RETRIEVED');
|
||||
assert.equal(retrieved.remainingQuantity, 0);
|
||||
assert.equal(retrieved.movements.reduce(
|
||||
(sum, movement) => sum + Number(movement.quantity), 0
|
||||
), 3);
|
||||
|
||||
await assert.rejects(
|
||||
() => storages.listForManagement({
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['goods.storage.read'], storeIds: [] }
|
||||
}, { storeId, page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
const manual = await storages.createManual(adminActor, {
|
||||
requestId: 'm09d3-manual-retrieve', storeId, memberId: customerId,
|
||||
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||
items: [{ skuId, quantity: 3 }]
|
||||
});
|
||||
const manualItemId = String(manual.items[0].id);
|
||||
const staffPartial = await storages.retrieve(adminActor, manual.id, {
|
||||
requestId: 'm09d3-staff-retrieve', claimCredential: manual.claimCredential,
|
||||
items: [{ storageItemId: manualItemId, quantity: 1 }]
|
||||
});
|
||||
assert.equal(staffPartial.status, 'PARTIALLY_RETRIEVED');
|
||||
const rotated = await storages.rotateCredential(adminActor, manual.id, {
|
||||
requestId: 'm09d3-rotate-manual'
|
||||
});
|
||||
assert.match(rotated.claimCredential, /^m09d3_claim_/);
|
||||
await assert.rejects(
|
||||
() => storages.retrieve(adminActor, manual.id, {
|
||||
requestId: 'm09d3-before-rotate-credential',
|
||||
claimCredential: staffPartial.nextClaimCredential,
|
||||
items: [{ storageItemId: manualItemId, quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||
);
|
||||
|
||||
const cancelStorage = await storages.createManual(adminActor, {
|
||||
requestId: 'm09d3-manual-cancel', storeId, memberId: customerId,
|
||||
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||
items: [{ skuId, quantity: 1 }]
|
||||
});
|
||||
assert.equal((await storages.cancel(adminActor, cancelStorage.id, {
|
||||
requestId: 'm09d3-cancel', reason: 'manual storage cancelled by authorized staff'
|
||||
})).status, 'CANCELLED');
|
||||
|
||||
const expiringStorage = await storages.createManual(adminActor, {
|
||||
requestId: 'm09d3-manual-expire', storeId, memberId: customerId,
|
||||
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||
items: [{ skuId, quantity: 1 }]
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_storage_records SET expires_at = '2026-08-10 17:59:59.000'
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiringStorage.id]
|
||||
);
|
||||
assert.ok((await storages.expireDueForManagement(adminActor, { storeId, limit: 100 })).expired >= 1);
|
||||
assert.equal((await storages.getForManagement(adminActor, expiringStorage.id)).status, 'EXPIRED');
|
||||
|
||||
const [storageRows] = await pool.query(
|
||||
`SELECT remaining_quantity AS remainingQuantity
|
||||
FROM qipai_product_storage_records WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, stored.id]
|
||||
);
|
||||
assert.equal(Number(storageRows[0].remainingQuantity), 0);
|
||||
const [outboxRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_outbox_events
|
||||
WHERE tenant_id = ? AND aggregate_type = 'PRODUCT_STORAGE'`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(Number(outboxRows[0].total) >= 9);
|
||||
const [auditRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_audit_logs
|
||||
WHERE tenant_id = ? AND resource_type = 'PRODUCT_STORAGE'`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(Number(auditRows[0].total) >= 9);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_storage_events SET reason = 'forbidden'
|
||||
WHERE tenant_id = ? AND storage_id = ? LIMIT 1`,
|
||||
[context.tenantId, stored.id]
|
||||
),
|
||||
(error) => /PRODUCT_STORAGE_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`DELETE FROM qipai_product_storage_event_items
|
||||
WHERE tenant_id = ? AND storage_id = ? LIMIT 1`,
|
||||
[context.tenantId, stored.id]
|
||||
),
|
||||
(error) => /PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
console.log(
|
||||
'PASS: M09-D3 order/manual storage, one-time credentials, partial and concurrent '
|
||||
+ 'retrieval, expiry, cancellation, isolation and immutable ledgers are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4364,7 +4675,8 @@ try {
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4389,13 +4701,14 @@ try {
|
||||
await assertCleaningTaskTransactions(pool, loginContext);
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||
await assertProductStorageLifecycle(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-D2 migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-D3 migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4427,7 +4740,8 @@ try {
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { ProductStorageError } from '../dist/products/product-storage-service.js';
|
||||
import { registerProductStorageRoutes } from '../dist/routes/product-storages.js';
|
||||
|
||||
const secret = 'test-only-product-storage-route-jwt-secret';
|
||||
const sessionId = '80f21c7c-4c1d-4e62-bb82-301c6d51adea';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d3-product-storage-route'
|
||||
};
|
||||
let currentAccess = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const calls = [];
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const storage = {
|
||||
id: '301', storeId: '11', memberId: '21', storageNo: 'PS301',
|
||||
status: 'STORED', totalQuantity: 3, remainingQuantity: 3,
|
||||
claimCredentialVersion: 1, claimCredentialConsumed: false,
|
||||
items: [{ id: '401', skuId: '5', totalQuantity: 3, remainingQuantity: 3 }]
|
||||
};
|
||||
const service = {
|
||||
createFromOrder: record('createFromOrder', () => ({
|
||||
...storage, claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345'
|
||||
})),
|
||||
createManual: record('createManual', () => storage),
|
||||
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForCustomer: record('getForCustomer', (_actor, storageId) => {
|
||||
if (storageId === '999') throw new ProductStorageError('PRODUCT_STORAGE_NOT_FOUND');
|
||||
return storage;
|
||||
}),
|
||||
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => storage),
|
||||
retrieve: record('retrieve', () => ({
|
||||
...storage, status: 'PARTIALLY_RETRIEVED', remainingQuantity: 2,
|
||||
nextClaimCredential: 'next_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||
})),
|
||||
rotateCredential: record('rotateCredential', () => ({
|
||||
...storage, claimCredentialVersion: 2,
|
||||
claimCredential: 'rotated_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||
})),
|
||||
cancel: record('cancel', () => ({ ...storage, status: 'CANCELLED' })),
|
||||
expireDueForManagement: record('expireDueForManagement', () => ({
|
||||
expired: 1, storageIds: ['301']
|
||||
}))
|
||||
};
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||
reply.header('x-trace-id', request.traceId);
|
||||
});
|
||||
await registerProductStorageRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId, tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
jwtSecret: secret
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-storages' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
|
||||
const invalidCreate = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages', headers,
|
||||
payload: { requestId: 'storage-create-invalid', sourceOrderId: '101', expiresAt: 'bad-date' }
|
||||
});
|
||||
assert.equal(invalidCreate.statusCode, 400);
|
||||
assert.equal(invalidCreate.json().code, 'PRODUCT_STORAGE_INPUT_INVALID');
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages', headers,
|
||||
payload: {
|
||||
requestId: 'storage-create-1', sourceOrderId: '101',
|
||||
expiresAt: '2026-08-12T07:00:00.000Z',
|
||||
items: [{ orderItemId: '201', quantity: 3 }]
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.id, '301');
|
||||
const createCall = calls.find((call) => call.method === 'createFromOrder');
|
||||
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||
assert.ok(createCall.args[1].expiresAt instanceof Date);
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-storages?status=STORED&page=2&pageSize=10', headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data.page, 2);
|
||||
|
||||
const retrieved = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages/301/retrieve', headers,
|
||||
payload: {
|
||||
requestId: 'storage-retrieve-1',
|
||||
claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345',
|
||||
items: [{ storageItemId: '401', quantity: 1 }]
|
||||
}
|
||||
});
|
||||
assert.equal(retrieved.statusCode, 200);
|
||||
assert.equal(retrieved.json().data.status, 'PARTIALLY_RETRIEVED');
|
||||
|
||||
const missing = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-storages/999', headers
|
||||
});
|
||||
assert.equal(missing.statusCode, 404);
|
||||
|
||||
const managementForbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-storages?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementForbidden.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.storage.read'], storeIds: ['11']
|
||||
};
|
||||
const managementList = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/product-storages?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
|
||||
const readOnlyCancel = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages/301/cancel', headers,
|
||||
payload: { requestId: 'storage-cancel-1', reason: '管理员取消' }
|
||||
});
|
||||
assert.equal(readOnlyCancel.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'],
|
||||
capabilities: ['goods.storage.read', 'goods.storage.manage'], storeIds: ['11']
|
||||
};
|
||||
const manual = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages', headers,
|
||||
payload: {
|
||||
sourceType: 'MANUAL', requestId: 'storage-manual-1', storeId: '11', memberId: '21',
|
||||
expiresAt: '2026-08-12T07:00:00.000Z', items: [{ skuId: '5', quantity: 2 }]
|
||||
}
|
||||
});
|
||||
assert.equal(manual.statusCode, 201);
|
||||
assert.ok(calls.some((call) => call.method === 'createManual'));
|
||||
|
||||
const expired = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages/expire-due', headers,
|
||||
payload: { storeId: '11', limit: 25 }
|
||||
});
|
||||
assert.equal(expired.statusCode, 200);
|
||||
assert.equal(expired.json().data.expired, 1);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D3 storage routes enforce ownership, credential input and management permissions.');
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ProductStorageError,
|
||||
ProductStorageService,
|
||||
productStorageCredentialDigest,
|
||||
resolveProductStorageRetrieval
|
||||
} from '../dist/products/product-storage-service.js';
|
||||
|
||||
const credential = 'claim_credential_abcdefghijklmnopqrstuvwxyz012345';
|
||||
const digest = productStorageCredentialDigest(credential);
|
||||
assert.match(digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(digest.includes(credential), false);
|
||||
assert.equal(productStorageCredentialDigest(credential), digest);
|
||||
assert.notEqual(productStorageCredentialDigest(`${credential}x`), digest);
|
||||
|
||||
assert.deepEqual(resolveProductStorageRetrieval('STORED', 5, 2), {
|
||||
remainingQuantity: 3, status: 'PARTIALLY_RETRIEVED'
|
||||
});
|
||||
assert.deepEqual(resolveProductStorageRetrieval('PARTIALLY_RETRIEVED', 3, 3), {
|
||||
remainingQuantity: 0, status: 'RETRIEVED'
|
||||
});
|
||||
for (const [status, quantity] of [['RETRIEVED', 1], ['EXPIRED', 1], ['CANCELLED', 1]]) {
|
||||
assert.throws(
|
||||
() => resolveProductStorageRetrieval(status, 1, quantity),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_NOT_ACTIVE'
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => resolveProductStorageRetrieval('STORED', 2, 3),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_QUANTITY_EXCEEDED'
|
||||
);
|
||||
|
||||
const manager = {
|
||||
tenantId: '7', userId: '31',
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['goods.storage.read', 'goods.storage.manage'],
|
||||
storeIds: ['11']
|
||||
},
|
||||
source: 'MANAGEMENT', traceId: 'm09d3-storage-service',
|
||||
ip: '127.0.0.1', userAgent: 'storage-service-test'
|
||||
};
|
||||
const noSqlPool = {
|
||||
async execute() { throw new Error('SQL must not run for rejected input'); },
|
||||
async getConnection() { throw new Error('transaction must not start for rejected input'); }
|
||||
};
|
||||
const service = new ProductStorageService(noSqlPool, {
|
||||
now: () => new Date('2026-08-11T07:00:00.000Z'),
|
||||
credentialFactory: () => credential
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.createManual(manager, {
|
||||
requestId: 'storage-create-duplicate', storeId: '11', memberId: '21',
|
||||
expiresAt: new Date('2026-08-12T07:00:00.000Z'),
|
||||
items: [{ skuId: '5', quantity: 1 }, { skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_ITEMS_INVALID'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.createFromOrder({ ...manager, source: 'CUSTOMER', userId: '21' }, {
|
||||
requestId: 'storage-expiry-invalid', sourceOrderId: '101',
|
||||
expiresAt: new Date('2026-08-11T07:00:30.000Z')
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_EXPIRY_INVALID'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.retrieve({ ...manager, source: 'CUSTOMER', userId: '21' }, '301', {
|
||||
requestId: 'storage-retrieve-invalid-credential', claimCredential: 'short',
|
||||
items: [{ storageItemId: '401', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof ProductStorageError
|
||||
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||
);
|
||||
|
||||
console.log('PASS: M09-D3 credential hashing, retrieval state transitions and input boundaries work.');
|
||||
Reference in New Issue
Block a user