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
@@ -0,0 +1,21 @@
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_delete;
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_update;
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_delete;
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_update;
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('goods.storage.read', 'goods.storage.manage');
DELETE FROM qipai_permissions
WHERE code IN ('goods.storage.read', 'goods.storage.manage');
DROP TABLE IF EXISTS qipai_product_storage_event_items;
DROP TABLE IF EXISTS qipai_product_storage_events;
DROP TABLE IF EXISTS qipai_product_storage_items;
DROP TABLE IF EXISTS qipai_product_storage_records;
ALTER TABLE qipai_product_order_items
DROP INDEX uq_qipai_product_order_item_scope_id;
DELETE FROM qipai_schema_migrations WHERE version = '2026081109';
@@ -0,0 +1,249 @@
ALTER TABLE qipai_product_order_items
ADD UNIQUE KEY uq_qipai_product_order_item_scope_id
(tenant_id, store_id, order_id, id);
CREATE TABLE IF NOT EXISTS qipai_product_storage_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NOT NULL,
source_order_id BIGINT UNSIGNED NULL,
storage_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
client_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
source_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'STORED',
item_count SMALLINT UNSIGNED NOT NULL,
total_quantity BIGINT UNSIGNED NOT NULL,
remaining_quantity BIGINT UNSIGNED NOT NULL,
expires_at DATETIME(3) NOT NULL,
claim_credential_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
claim_credential_version INT UNSIGNED NOT NULL DEFAULT 1,
claim_credential_rotated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
claim_credential_consumed_at DATETIME(3) NULL,
created_source VARCHAR(16) NOT NULL,
created_by BIGINT UNSIGNED NULL,
cancelled_at DATETIME(3) NULL,
cancel_reason VARCHAR(512) NOT NULL DEFAULT '',
expired_at DATETIME(3) NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_storage_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_storage_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_storage_member
FOREIGN KEY (tenant_id, member_id) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_storage_source_order
FOREIGN KEY (tenant_id, store_id, source_order_id)
REFERENCES qipai_product_orders(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_storage_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_storage_scope_id (tenant_id, store_id, id),
UNIQUE KEY uq_qipai_product_storage_no (tenant_id, storage_no),
UNIQUE KEY uq_qipai_product_storage_client_request (tenant_id, client_request_id),
UNIQUE KEY uq_qipai_product_storage_source_order (tenant_id, source_order_id),
KEY idx_qipai_product_storage_store_status
(tenant_id, store_id, status, expires_at, id),
KEY idx_qipai_product_storage_member
(tenant_id, member_id, created_at, id),
CONSTRAINT chk_qipai_product_storage_source CHECK (
(source_type = 'ORDER' AND source_order_id IS NOT NULL)
OR (source_type = 'MANUAL' AND source_order_id IS NULL)
),
CONSTRAINT chk_qipai_product_storage_status CHECK (
status IN ('STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED')
),
CONSTRAINT chk_qipai_product_storage_counts CHECK (
item_count BETWEEN 1 AND 100
AND total_quantity BETWEEN 1 AND 1000000000
AND remaining_quantity <= total_quantity
),
CONSTRAINT chk_qipai_product_storage_credential CHECK (
claim_credential_hash REGEXP '^[0-9a-f]{64}$'
AND claim_credential_version >= 1
),
CONSTRAINT chk_qipai_product_storage_actor CHECK (
created_source IN ('MEMBER', 'STAFF', 'SYSTEM')
AND (created_source <> 'MEMBER' OR source_type = 'ORDER')
AND (created_source <> 'STAFF' OR created_by IS NOT NULL)
),
CONSTRAINT chk_qipai_product_storage_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_storage_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
storage_id BIGINT UNSIGNED NOT NULL,
line_no SMALLINT UNSIGNED NOT NULL,
source_order_id BIGINT UNSIGNED NULL,
source_order_item_id BIGINT UNSIGNED NULL,
product_id BIGINT UNSIGNED NOT NULL,
sku_id BIGINT UNSIGNED NOT NULL,
product_code VARCHAR(64) NOT NULL,
product_name VARCHAR(128) NOT NULL,
sku_code VARCHAR(64) NOT NULL,
sku_name VARCHAR(128) NOT NULL,
unit_name VARCHAR(32) NOT NULL,
attributes_snapshot JSON NULL,
total_quantity BIGINT UNSIGNED NOT NULL,
remaining_quantity BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_storage_item_record
FOREIGN KEY (tenant_id, store_id, storage_id)
REFERENCES qipai_product_storage_records(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_storage_item_source
FOREIGN KEY (tenant_id, store_id, source_order_id, source_order_item_id)
REFERENCES qipai_product_order_items(tenant_id, store_id, order_id, id),
CONSTRAINT fk_qipai_product_storage_item_product
FOREIGN KEY (tenant_id, product_id) REFERENCES qipai_products(tenant_id, id),
CONSTRAINT fk_qipai_product_storage_item_sku
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
UNIQUE KEY uq_qipai_product_storage_item_line
(tenant_id, store_id, storage_id, line_no),
UNIQUE KEY uq_qipai_product_storage_item_source
(tenant_id, source_order_item_id),
UNIQUE KEY uq_qipai_product_storage_item_scope_id
(tenant_id, store_id, storage_id, id),
KEY idx_qipai_product_storage_item_sku
(tenant_id, store_id, sku_id, created_at),
CONSTRAINT chk_qipai_product_storage_item_line CHECK (line_no BETWEEN 1 AND 100),
CONSTRAINT chk_qipai_product_storage_item_source CHECK (
(source_order_id IS NULL AND source_order_item_id IS NULL)
OR (source_order_id IS NOT NULL AND source_order_item_id IS NOT NULL)
),
CONSTRAINT chk_qipai_product_storage_item_quantity CHECK (
total_quantity BETWEEN 1 AND 1000000000
AND remaining_quantity <= total_quantity
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_storage_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
storage_id BIGINT UNSIGNED NOT NULL,
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL,
quantity BIGINT UNSIGNED NOT NULL DEFAULT 0,
actor_type VARCHAR(16) NOT NULL,
actor_id BIGINT UNSIGNED NULL,
credential_version_after INT UNSIGNED NOT NULL,
version_after INT UNSIGNED NOT NULL,
reason VARCHAR(512) NOT NULL DEFAULT '',
trace_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_storage_event_record
FOREIGN KEY (tenant_id, store_id, storage_id)
REFERENCES qipai_product_storage_records(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_storage_event_actor
FOREIGN KEY (tenant_id, actor_id) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_storage_event_request (tenant_id, request_id),
UNIQUE KEY uq_qipai_product_storage_event_version
(tenant_id, store_id, storage_id, version_after),
UNIQUE KEY uq_qipai_product_storage_event_scope_id
(tenant_id, store_id, storage_id, id),
KEY idx_qipai_product_storage_event_history
(tenant_id, store_id, storage_id, created_at, id),
CONSTRAINT chk_qipai_product_storage_event_fingerprint CHECK (
request_fingerprint REGEXP '^[0-9a-f]{64}$'
),
CONSTRAINT chk_qipai_product_storage_event_from_status CHECK (
from_status IS NULL OR from_status IN (
'STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'
)
),
CONSTRAINT chk_qipai_product_storage_event_to_status CHECK (
to_status IN ('STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED')
),
CONSTRAINT chk_qipai_product_storage_event_action CHECK (
action IN ('STORED', 'RETRIEVED', 'CREDENTIAL_ROTATED', 'CANCELLED', 'EXPIRED')
),
CONSTRAINT chk_qipai_product_storage_event_actor CHECK (
actor_type IN ('MEMBER', 'STAFF', 'SYSTEM')
AND (actor_type <> 'STAFF' OR actor_id IS NOT NULL)
),
CONSTRAINT chk_qipai_product_storage_event_versions CHECK (
credential_version_after >= 1 AND version_after >= 1
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_storage_event_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
storage_id BIGINT UNSIGNED NOT NULL,
event_id BIGINT UNSIGNED NOT NULL,
storage_item_id BIGINT UNSIGNED NOT NULL,
line_no SMALLINT UNSIGNED NOT NULL,
sku_id BIGINT UNSIGNED NOT NULL,
quantity BIGINT UNSIGNED NOT NULL,
remaining_after BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_storage_event_item_event
FOREIGN KEY (tenant_id, store_id, storage_id, event_id)
REFERENCES qipai_product_storage_events(tenant_id, store_id, storage_id, id),
CONSTRAINT fk_qipai_product_storage_event_item_storage_item
FOREIGN KEY (tenant_id, store_id, storage_id, storage_item_id)
REFERENCES qipai_product_storage_items(tenant_id, store_id, storage_id, id),
CONSTRAINT fk_qipai_product_storage_event_item_sku
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
UNIQUE KEY uq_qipai_product_storage_event_item_line
(tenant_id, store_id, storage_id, event_id, line_no),
KEY idx_qipai_product_storage_event_item_storage
(tenant_id, store_id, storage_id, storage_item_id, created_at),
CONSTRAINT chk_qipai_product_storage_event_item_quantity CHECK (
line_no BETWEEN 1 AND 100
AND quantity BETWEEN 1 AND 1000000000
AND remaining_after <= 1000000000
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_update;
CREATE TRIGGER qipai_product_storage_events_no_update
BEFORE UPDATE ON qipai_product_storage_events
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_IMMUTABLE';
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_delete;
CREATE TRIGGER qipai_product_storage_events_no_delete
BEFORE DELETE ON qipai_product_storage_events
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_IMMUTABLE';
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_update;
CREATE TRIGGER qipai_product_storage_event_items_no_update
BEFORE UPDATE ON qipai_product_storage_event_items
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE';
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_delete;
CREATE TRIGGER qipai_product_storage_event_items_no_delete
BEFORE DELETE ON qipai_product_storage_event_items
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE';
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('goods.storage.read', '查看商品寄存', 'goods'),
('goods.storage.manage', '处理商品寄存', 'goods');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id
FROM qipai_roles r
INNER JOIN qipai_permissions p
ON p.code IN ('goods.storage.read', 'goods.storage.manage')
WHERE r.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND r.status = 'ACTIVE'
AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081109', 'm09d3_product_storage');
@@ -0,0 +1,148 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'qipai_product_storage_records',
'qipai_product_storage_items',
'qipai_product_storage_events',
'qipai_product_storage_event_items'
)
ORDER BY table_name;
SELECT table_name, column_name, data_type, is_nullable, collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND (
(table_name = 'qipai_product_storage_records' AND column_name IN (
'storage_no', 'client_request_id', 'request_fingerprint', 'source_type', 'status',
'total_quantity', 'remaining_quantity', 'expires_at', 'claim_credential_hash',
'claim_credential_version', 'claim_credential_consumed_at', 'version'
))
OR (table_name = 'qipai_product_storage_items' AND column_name IN (
'source_order_item_id', 'product_code', 'product_name', 'sku_code', 'sku_name',
'attributes_snapshot', 'total_quantity', 'remaining_quantity'
))
OR (table_name = 'qipai_product_storage_events' AND column_name IN (
'request_id', 'request_fingerprint', 'action', 'quantity',
'credential_version_after', 'version_after'
))
OR (table_name = 'qipai_product_storage_event_items' AND column_name IN (
'storage_item_id', 'sku_id', 'quantity', 'remaining_after'
))
)
ORDER BY table_name, ordinal_position;
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN (
'uq_qipai_product_order_item_scope_id',
'uq_qipai_product_storage_scope_id',
'uq_qipai_product_storage_no',
'uq_qipai_product_storage_client_request',
'uq_qipai_product_storage_source_order',
'idx_qipai_product_storage_store_status',
'idx_qipai_product_storage_member',
'uq_qipai_product_storage_item_line',
'uq_qipai_product_storage_item_source',
'uq_qipai_product_storage_item_scope_id',
'idx_qipai_product_storage_item_sku',
'uq_qipai_product_storage_event_request',
'uq_qipai_product_storage_event_version',
'uq_qipai_product_storage_event_scope_id',
'idx_qipai_product_storage_event_history',
'uq_qipai_product_storage_event_item_line',
'idx_qipai_product_storage_event_item_storage'
)
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
SELECT constraint_name
FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_type = 'FOREIGN KEY'
AND constraint_name IN (
'fk_qipai_product_storage_tenant',
'fk_qipai_product_storage_store',
'fk_qipai_product_storage_member',
'fk_qipai_product_storage_source_order',
'fk_qipai_product_storage_created_by',
'fk_qipai_product_storage_item_record',
'fk_qipai_product_storage_item_source',
'fk_qipai_product_storage_item_product',
'fk_qipai_product_storage_item_sku',
'fk_qipai_product_storage_event_record',
'fk_qipai_product_storage_event_actor',
'fk_qipai_product_storage_event_item_event',
'fk_qipai_product_storage_event_item_storage_item',
'fk_qipai_product_storage_event_item_sku'
)
ORDER BY constraint_name;
SELECT constraint_name
FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_type = 'CHECK'
AND constraint_name IN (
'chk_qipai_product_storage_source',
'chk_qipai_product_storage_status',
'chk_qipai_product_storage_counts',
'chk_qipai_product_storage_credential',
'chk_qipai_product_storage_actor',
'chk_qipai_product_storage_version',
'chk_qipai_product_storage_item_line',
'chk_qipai_product_storage_item_source',
'chk_qipai_product_storage_item_quantity',
'chk_qipai_product_storage_event_fingerprint',
'chk_qipai_product_storage_event_from_status',
'chk_qipai_product_storage_event_to_status',
'chk_qipai_product_storage_event_action',
'chk_qipai_product_storage_event_actor',
'chk_qipai_product_storage_event_versions',
'chk_qipai_product_storage_event_item_quantity'
)
ORDER BY constraint_name;
SELECT trigger_name, event_manipulation
FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN (
'qipai_product_storage_events_no_update',
'qipai_product_storage_events_no_delete',
'qipai_product_storage_event_items_no_update',
'qipai_product_storage_event_items_no_delete'
)
ORDER BY trigger_name;
SELECT code
FROM qipai_permissions
WHERE code IN ('goods.storage.read', 'goods.storage.manage')
ORDER BY code;
SELECT COUNT(*) AS managed_role_count
FROM qipai_roles r
WHERE r.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND r.status = 'ACTIVE'
AND r.deleted_at IS NULL
HAVING COUNT(*) = (
SELECT COUNT(*)
FROM (
SELECT granted_role.tenant_id, granted_role.id
FROM qipai_roles granted_role
INNER JOIN qipai_role_permissions rp
ON rp.tenant_id = granted_role.tenant_id AND rp.role_id = granted_role.id
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE granted_role.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND granted_role.status = 'ACTIVE'
AND granted_role.deleted_at IS NULL
AND p.code IN ('goods.storage.read', 'goods.storage.manage')
GROUP BY granted_role.tenant_id, granted_role.id
HAVING COUNT(DISTINCT p.code) = 2
) fully_granted_goods_storage_roles
);
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081109'
AND name = 'm09d3_product_storage'
AND checksum REGEXP '^[0-9a-f]{64}$';