feat(M09-D3): 完成商品寄存与安全取出闭环

This commit is contained in:
Codex
2026-08-11 07:39:46 +08:00
parent 1a3ea7bdf0
commit 60264cbd11
28 changed files with 2735 additions and 72 deletions
@@ -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.');