-- +goose Up -- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。 -- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。 CREATE TABLE task_claim_upgrade_guard ( valid INTEGER NOT NULL CHECK (valid = 1) ); INSERT INTO task_claim_upgrade_guard (valid) SELECT CASE WHEN (SELECT COUNT(*) FROM purchase_attempts) = 0 AND (SELECT COUNT(*) FROM order_submissions) = 0 AND (SELECT COUNT(*) FROM evidence_assets) = 0 THEN 1 ELSE 0 END; DROP TABLE task_claim_upgrade_guard; -- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。 CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx ON purchase_attempts (authorization_id); -- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。 CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx ON purchase_attempts (task_id, authorization_id, id, claim_generation); CREATE TABLE purchase_attempt_claims ( attempt_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, authorization_id TEXT NOT NULL UNIQUE, claimed_by_device_id TEXT NOT NULL, session_id TEXT NOT NULL CHECK ( length(session_id) = 36 AND substr(session_id, 9, 1) = '-' AND substr(session_id, 14, 1) = '-' AND substr(session_id, 19, 1) = '-' AND substr(session_id, 24, 1) = '-' AND length(replace(session_id, '-', '')) = 32 AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(session_id, 15, 1) = '4' AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') ), claim_generation INTEGER NOT NULL CHECK ( typeof(claim_generation) = 'integer' AND claim_generation > 0 ), task_version INTEGER NOT NULL CHECK ( typeof(task_version) = 'integer' AND task_version > 0 ), task_title TEXT NOT NULL CHECK (trim(task_title) <> ''), authorization_task_version INTEGER NOT NULL CHECK ( typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0 ), goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''), sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''), sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''), quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0), total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''), authorization_expires_at TEXT NOT NULL CHECK ( authorization_expires_at = trim(authorization_expires_at) AND length(authorization_expires_at) >= 20 AND substr(authorization_expires_at, 11, 1) = 'T' AND substr(authorization_expires_at, -1, 1) = 'Z' AND julianday(authorization_expires_at) IS NOT NULL ), claim_nonce BLOB NOT NULL CHECK ( typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32 ), claim_token_sha256 BLOB NOT NULL CHECK ( typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32 ), lease_expires_at TEXT NOT NULL CHECK ( lease_expires_at = trim(lease_expires_at) AND length(lease_expires_at) >= 20 AND substr(lease_expires_at, 11, 1) = 'T' AND substr(lease_expires_at, -1, 1) = 'Z' AND julianday(lease_expires_at) IS NOT NULL ), claimed_at TEXT NOT NULL CHECK ( claimed_at = trim(claimed_at) AND length(claimed_at) >= 20 AND substr(claimed_at, 11, 1) = 'T' AND substr(claimed_at, -1, 1) = 'Z' AND julianday(claimed_at) IS NOT NULL ), closed_at TEXT CHECK ( closed_at IS NULL OR ( closed_at = trim(closed_at) AND length(closed_at) >= 20 AND substr(closed_at, 11, 1) = 'T' AND substr(closed_at, -1, 1) = 'Z' AND julianday(closed_at) IS NOT NULL AND julianday(closed_at) >= julianday(claimed_at) ) ), UNIQUE (task_id, attempt_id), UNIQUE (attempt_id, claimed_by_device_id, session_id), UNIQUE ( task_id, attempt_id, claimed_by_device_id, session_id, claim_generation, claim_token_sha256 ), UNIQUE (task_id, authorization_id, attempt_id), FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation) REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation), FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id) ); -- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。 CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL; CREATE TABLE task_claim_requests ( claim_request_id TEXT PRIMARY KEY CHECK ( length(claim_request_id) = 36 AND substr(claim_request_id, 9, 1) = '-' AND substr(claim_request_id, 14, 1) = '-' AND substr(claim_request_id, 19, 1) = '-' AND substr(claim_request_id, 24, 1) = '-' AND length(replace(claim_request_id, '-', '')) = 32 AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(claim_request_id, 15, 1) = '4' AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b') ), device_id TEXT NOT NULL, session_id TEXT NOT NULL CHECK ( length(session_id) = 36 AND substr(session_id, 9, 1) = '-' AND substr(session_id, 14, 1) = '-' AND substr(session_id, 19, 1) = '-' AND substr(session_id, 24, 1) = '-' AND length(replace(session_id, '-', '')) = 32 AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(session_id, 15, 1) = '4' AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') ), outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')), attempt_id TEXT, response_lease_expires_at TEXT CHECK ( response_lease_expires_at IS NULL OR ( response_lease_expires_at = trim(response_lease_expires_at) AND length(response_lease_expires_at) >= 20 AND substr(response_lease_expires_at, 11, 1) = 'T' AND substr(response_lease_expires_at, -1, 1) = 'Z' AND julianday(response_lease_expires_at) IS NOT NULL ) ), error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'), created_at TEXT NOT NULL CHECK ( created_at = trim(created_at) AND length(created_at) >= 20 AND substr(created_at, 11, 1) = 'T' AND substr(created_at, -1, 1) = 'Z' AND julianday(created_at) IS NOT NULL ), CHECK ( (outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL) OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL) OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required') ), FOREIGN KEY (device_id) REFERENCES device_credentials(device_id), -- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须 -- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。 FOREIGN KEY (attempt_id, device_id, session_id) REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id) ); CREATE TABLE purchase_attempt_lease_renewals ( renew_request_id TEXT PRIMARY KEY CHECK ( length(renew_request_id) = 36 AND substr(renew_request_id, 9, 1) = '-' AND substr(renew_request_id, 14, 1) = '-' AND substr(renew_request_id, 19, 1) = '-' AND substr(renew_request_id, 24, 1) = '-' AND length(replace(renew_request_id, '-', '')) = 32 AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(renew_request_id, 15, 1) = '4' AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b') ), task_id TEXT NOT NULL, attempt_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL CHECK ( length(session_id) = 36 AND substr(session_id, 9, 1) = '-' AND substr(session_id, 14, 1) = '-' AND substr(session_id, 19, 1) = '-' AND substr(session_id, 24, 1) = '-' AND length(replace(session_id, '-', '')) = 32 AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(session_id, 15, 1) = '4' AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') ), claim_generation INTEGER NOT NULL CHECK ( typeof(claim_generation) = 'integer' AND claim_generation > 0 ), claim_token_sha256 BLOB NOT NULL CHECK ( typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32 ), expected_lease_expires_at TEXT NOT NULL CHECK ( expected_lease_expires_at = trim(expected_lease_expires_at) AND length(expected_lease_expires_at) >= 20 AND substr(expected_lease_expires_at, 11, 1) = 'T' AND substr(expected_lease_expires_at, -1, 1) = 'Z' AND julianday(expected_lease_expires_at) IS NOT NULL ), lease_expires_at TEXT NOT NULL CHECK ( lease_expires_at = trim(lease_expires_at) AND length(lease_expires_at) >= 20 AND substr(lease_expires_at, 11, 1) = 'T' AND substr(lease_expires_at, -1, 1) = 'Z' AND julianday(lease_expires_at) IS NOT NULL ), created_at TEXT NOT NULL CHECK ( created_at = trim(created_at) AND length(created_at) >= 20 AND substr(created_at, 11, 1) = 'T' AND substr(created_at, -1, 1) = 'Z' AND julianday(created_at) IS NOT NULL ), FOREIGN KEY ( task_id, attempt_id, device_id, session_id, claim_generation, claim_token_sha256 ) REFERENCES purchase_attempt_claims( task_id, attempt_id, claimed_by_device_id, session_id, claim_generation, claim_token_sha256 ) ); CREATE INDEX order_authorizations_claim_candidate_idx ON order_authorizations (status, created_at, id); -- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT, -- 因而未来人工关闭 claim 后仍可稳定返回原资产。 -- +goose StatementBegin CREATE TRIGGER evidence_assets_claim_owner_insert BEFORE INSERT ON evidence_assets FOR EACH ROW WHEN NOT EXISTS ( SELECT 1 FROM purchase_attempt_claims AS claims WHERE claims.task_id = NEW.task_id AND claims.attempt_id = NEW.attempt_id AND claims.claimed_by_device_id = NEW.uploaded_by_device_id AND claims.closed_at IS NULL ) BEGIN SELECT RAISE(ABORT, 'evidence claim ownership required'); END; -- +goose StatementEnd -- +goose Down -- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。 CREATE TABLE task_claim_downgrade_guard ( valid INTEGER NOT NULL CHECK (valid = 1) ); INSERT INTO task_claim_downgrade_guard (valid) SELECT CASE WHEN (SELECT COUNT(*) FROM task_claim_requests) = 0 AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0 AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0 AND (SELECT COUNT(*) FROM purchase_attempts) = 0 AND (SELECT COUNT(*) FROM order_submissions) = 0 AND (SELECT COUNT(*) FROM evidence_assets) = 0 THEN 1 ELSE 0 END; DROP TABLE task_claim_downgrade_guard; DROP TRIGGER evidence_assets_claim_owner_insert; DROP INDEX order_authorizations_claim_candidate_idx; DROP TABLE purchase_attempt_lease_renewals; DROP TABLE task_claim_requests; DROP INDEX purchase_attempt_claims_one_open_per_device_idx; DROP TABLE purchase_attempt_claims; DROP INDEX purchase_attempts_claim_lineage_idx; DROP INDEX purchase_attempts_one_per_authorization_idx;