fix(selfop): persist bounded next-root quantity diagnostics

This commit is contained in:
2026-10-14 04:59:05 +08:00
parent 765b6e6208
commit 913b9e9262
17 changed files with 1142 additions and 914 deletions
@@ -2,16 +2,20 @@
-- Run only after explicit TEST SQL approval. Not executed by service startup.
CREATE TABLE ll_selfop_quantity_diagnostic_session (
diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '固定有界数量诊断会话标识',
configuration_hash CHAR(64) NOT NULL COMMENT '冻结范围、UTC起止时间及限额的SHA256摘要',
configuration_hash CHAR(64) NOT NULL COMMENT '冻结授权范围的SHA256摘要',
source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名,不保存凭据',
collection_session_id VARCHAR(128) NOT NULL COMMENT '被观察的来源采集会话标识',
root_task_id VARCHAR(128) NOT NULL COMMENT '被观察的账号采集根任务标识',
collection_session_id VARCHAR(128) NULL COMMENT '被观察的来源采集会话标识',
root_task_id VARCHAR(128) NULL COMMENT '被观察的账号采集根任务标识',
starts_at VARCHAR(40) NOT NULL COMMENT '诊断开始时刻,ISO8601 UTC字符串',
expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,距开始最多30分钟,ISO8601 UTC字符串',
expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,首个LIST观测后最多4小时且授权后最多10小时,ISO8601 UTC字符串',
accepted_orders INT NOT NULL DEFAULT 0 COMMENT '已接收完整配对订单数,跨重启累计',
accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '已接收归并SKU行数,跨重启累计',
accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '两侧已保留SKU行数,跨重启累计',
accepted_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已持久化白名单规范JSON的UTF8字节数',
stop_reason VARCHAR(64) NULL COMMENT '诊断终止原因,空值表示尚未终止',
active_slot INT NULL COMMENT '当前授权占位,终态释放;旧证据不自动授权',
state_json LONGTEXT NULL COMMENT '新授权的持久生命周期;NULL仅表示未参与此协议的历史证据',
UNIQUE KEY uq_qdiag_active (source_account, active_slot),
CONSTRAINT chk_qdiag_active CHECK (active_slot IS NULL OR active_slot = 1),
PRIMARY KEY (diagnostic_session_id),
CONSTRAINT chk_selfop_qdiag_limits CHECK (accepted_orders BETWEEN 0 AND 100
AND accepted_sku_rows BETWEEN 0 AND 2000 AND accepted_bytes BETWEEN 0 AND 2097152)
@@ -26,7 +30,7 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order (
order_no VARCHAR(128) NOT NULL COMMENT '来源出库单号',
payload_hash CHAR(64) NOT NULL COMMENT '白名单规范载荷SHA256摘要,用于幂等校验',
comparison VARCHAR(40) NOT NULL COMMENT 'SKU集合与三类数量比较结果',
sku_rows INT NOT NULL COMMENT '本单归并后的SKU并集行数',
sku_rows INT NOT NULL COMMENT '本单两侧较大的SKU行数;两侧累计额度由会话约束',
payload_bytes INT NOT NULL COMMENT '本单白名单规范JSON的UTF8字节数',
evidence_json JSON NOT NULL COMMENT '类型化列表与详情观测及SKU三量证据,不含原始响应或收件信息',
received_at VARCHAR(40) NOT NULL COMMENT '接收时刻,ISO8601 UTC字符串',
@@ -36,3 +40,14 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order (
CONSTRAINT chk_selfop_qdiag_order_limits CHECK (sku_rows BETWEEN 1 AND 200
AND payload_bytes BETWEEN 1 AND 262144)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立配对订单数量诊断证据,不参与销量发布';
CREATE TABLE ll_selfop_quantity_diagnostic_pending (
diagnostic_session_id VARCHAR(128) NOT NULL,
order_key_hash CHAR(64) NOT NULL,
warehouse_code VARCHAR(64) NOT NULL,
owner_code VARCHAR(128) NOT NULL,
order_no VARCHAR(128) NOT NULL,
evidence_json LONGTEXT NOT NULL,
PRIMARY KEY(diagnostic_session_id,order_key_hash),
FOREIGN KEY(diagnostic_session_id) REFERENCES ll_selfop_quantity_diagnostic_session(diagnostic_session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='首次LIST白名单证据;配对后同事务移至最终证据表';
@@ -1,40 +1,168 @@
openapi: 3.0.3
info:
title: Internal bounded self-operated quantity diagnostics
version: 1.0.0
title: Internal next-root bounded quantity diagnostics
version: 2.0.0
description: Explicit arm only; no automatic rearm. Bind next normal automatic historical OUTBOUND_QUERY root
within 6h. Sampling expires 4h after first LIST observedAt, never later than armedAt+10h. Lastleg session lock
is receiver acceptance. Source root creation may predate arm. LOCATE, manual/validation roots, current excluded
root and different bound root are rejected/ignored. At most 100 distinct LIST candidates total, 2000 retained
SKU rows across both sides, 2MiB canonical evidence plus promotion overhead. Pending-to-final never refunds
quota. First unknown or SKU mismatch persists INCONCLUSIVE and stops; any known inequality among list/forecast/actual
persists QUANTITY_DIFFERENCE and stops. Original side timestamps/attempts are immutable; exact replay returns
stored receipt even after termination, changed same-side evidence is rejected. Root termination drains only
accepted candidates via an explicit Integration FIFO boundary without extending expiry. Fewer than 100 complete
pairs is ROOT_ENDED_INSUFFICIENT; unpaired/lost tail is INCOMPLETE. Producer restart retains persisted candidates
but marks delivery uncertain. Every request/response is stream-bounded to 262144 bytes. Never exposed through
Gateway, never creates supplier requests or changes business facts.
paths:
/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders:
/internal/lastleg/self-operated-analytics/quantity-diagnostics/arm:
post:
operationId: receiveSelfopQuantityDiagnosticOrder
description: >-
Internal service-token only; never routed through Gateway. One complete paired order per request.
Quantities are evidence only. Session scope and UTC time interval are frozen by server configuration.
Maximum 100 orders, 2000 SKU rows and 2097152 canonical UTF8 bytes per session;
200 SKU rows and 262144 wire/canonical bytes per request; at most 30 minutes.
First known forecast/actual difference closes capture regardless of comparison classification.
Same identity and canonical evidence is idempotent, including after closure; changed evidence is 409.
operationId: quantityDiagnosticArm
security:
- internalServiceToken: []
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PairedOrder'
$ref: '#/components/schemas/Arm'
responses:
'200':
description: Diagnostic receipt in the standard ApiResponse wrapper; code is string 200.
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code: { type: string, example: '200' }
data: { $ref: '#/components/schemas/Receipt' }
'400': { description: 'Invalid JSON, unknown fields, invalid scope/evidence, duplicate SKU or request limit.' }
'401': { description: Invalid internal service token. }
'409': { description: Session configuration changed or order identity reused with different evidence. }
'503': { description: Internal service token unconfigured or diagnostic storage unavailable. }
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/status:
post:
operationId: quantityDiagnosticStatus
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/observe:
post:
operationId: quantityDiagnosticObserve
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Event'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/Receipt'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/stop:
post:
operationId: quantityDiagnosticStop
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Stop'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
components:
securitySchemes:
internalServiceToken:
@@ -42,64 +170,365 @@ components:
in: header
name: X-Internal-Service-Token
schemas:
PairedOrder:
Arm:
type: object
additionalProperties: false
required: [diagnosticSessionId, collectionSessionId, rootTaskId, sourceAccount, warehouseCode, ownerCode, orderNo, listObservation, detailObservation, skus]
properties:
diagnosticSessionId: { type: string, maxLength: 128 }
collectionSessionId: { type: string, maxLength: 128 }
rootTaskId: { type: string, maxLength: 128 }
sourceAccount: { type: string, maxLength: 64 }
warehouseCode: { type: string, maxLength: 64 }
ownerCode: { type: string, maxLength: 128 }
orderNo: { type: string, maxLength: 128 }
listObservation: { $ref: '#/components/schemas/Observation' }
detailObservation: { $ref: '#/components/schemas/Observation' }
skus:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
warehouseCodes:
type: array
minItems: 1
maxItems: 200
description: Union of list/detail SKUs; duplicate source lines aggregated, unknown never zero-filled.
items: { $ref: '#/components/schemas/Sku' }
maxItems: 8
uniqueItems: true
items:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
description: Explicit owner code, or * for all allowed owners.
excludedRootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
description: Must match the actual started, unfinished automatic root at arm; null only when none exists.
required:
- diagnosticSessionId
- sourceAccount
- warehouseCodes
- ownerCode
Status:
type: object
additionalProperties: false
properties:
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
attach:
type: boolean
description: True only on Integration process attachment; replacement marks delivery uncertain and fences
prior producer.
drained:
type: boolean
description: Only after observing DRAINING, closing intake and draining previously queued callbacks.
required:
- sourceAccount
- producerId
- attach
- drained
Stop:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
reason:
type: string
enum:
- INCOMPLETE
- QUEUE_FULL
- OBSERVER_FAILED
- SHUTDOWN
required:
- diagnosticSessionId
- sourceAccount
- producerId
- reason
Observation:
type: object
additionalProperties: false
required: [observedAt, attemptId, skuSetComplete]
properties:
status: { type: string, nullable: true, maxLength: 32 }
status:
type: string
maxLength: 32
minLength: 1
nullable: true
outboundTime:
type: string
maxLength: 64
minLength: 1
nullable: true
maxLength: 32
description: Original Asia/Shanghai source text, null if absent; not reinterpreted as another timezone.
observedAt: { type: string, format: date-time }
attemptId: { type: string, maxLength: 128 }
skuSetComplete: { type: boolean }
observedAt:
type: string
format: date-time
attemptId:
type: string
maxLength: 128
minLength: 1
skuSetComplete:
type: boolean
required:
- status
- outboundTime
- observedAt
- attemptId
- skuSetComplete
Sku:
type: object
additionalProperties: false
required: [sku, listPresent, detailPresent]
properties:
sku: { type: string, maxLength: 128 }
listQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
forecastQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
actualQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
listPresent: { type: boolean }
detailPresent: { type: boolean }
Receipt:
type: object
required: [outcome, acceptedOrders, acceptedSkuRows, acceptedBytes]
properties:
outcome: { type: string, enum: [ACCEPTED, DUPLICATE, STOPPED] }
comparison:
sku:
type: string
maxLength: 128
minLength: 1
listQty:
type: number
nullable: true
enum: [SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE]
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
forecastQty:
type: number
nullable: true
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
actualQty:
type: number
nullable: true
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
required:
- sku
- listQty
- forecastQty
- actualQty
Key:
type: object
additionalProperties: false
properties:
warehouseCode:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
orderNo:
type: string
maxLength: 128
minLength: 1
paired:
type: boolean
required:
- warehouseCode
- ownerCode
- orderNo
- paired
Event:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
rootTaskId:
type: string
maxLength: 128
minLength: 1
collectionSessionId:
type: string
maxLength: 128
minLength: 1
operation:
type: string
enum:
- OUTBOUND_QUERY
- OUTBOUND_DETAIL
warehouseCode:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
orderNo:
type: string
maxLength: 128
minLength: 1
side:
type: string
enum:
- LIST
- DETAIL
observation:
$ref: '#/components/schemas/Observation'
skus:
type: array
maxItems: 200
items:
$ref: '#/components/schemas/Sku'
description: Empty allowed only when skuSetComplete=false; persists inconclusive evidence.
required:
- diagnosticSessionId
- sourceAccount
- producerId
- rootTaskId
- collectionSessionId
- operation
- warehouseCode
- ownerCode
- orderNo
- side
- observation
- skus
View:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
rootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
collectionSessionId:
type: string
maxLength: 128
minLength: 1
nullable: true
state:
type: string
enum:
- ARMED
- BOUND
- DRAINING
- TERMINAL
stopReason:
type: string
nullable: true
enum: [DISABLED, NOT_STARTED, EXPIRED, LIMIT_REACHED, FORECAST_ACTUAL_DIFFERENCE]
acceptedOrders: { type: integer }
acceptedSkuRows: { type: integer }
acceptedBytes: { type: integer, format: int64, description: Canonical whitelist JSON UTF8 bytes persisted in this session. }
startsAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
bindBefore:
type: string
format: date-time
nullable: true
warehouseCodes:
type: array
maxItems: 8
items:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
excludedRootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
candidates:
type: integer
minimum: 0
maximum: 100
pairedOrders:
type: integer
minimum: 0
maximum: 100
retainedRows:
type: integer
minimum: 0
maximum: 2000
retainedBytes:
type: integer
minimum: 0
maximum: 2097152
deliveryUncertain:
type: boolean
selected:
type: array
maxItems: 100
items:
$ref: '#/components/schemas/Key'
required:
- diagnosticSessionId
- sourceAccount
- rootTaskId
- collectionSessionId
- state
- stopReason
- startsAt
- expiresAt
- bindBefore
- warehouseCodes
- ownerCode
- excludedRootTaskId
- candidates
- pairedOrders
- retainedRows
- retainedBytes
- deliveryUncertain
- selected
Receipt:
type: object
additionalProperties: false
properties:
outcome:
type: string
enum:
- ACCEPTED
- IGNORED
- DUPLICATE
- STOPPED
comparison:
type: string
nullable: true
enum:
- ALL_EQUAL
- QUANTITY_DIFFERENCE
- INCONCLUSIVE
session:
$ref: '#/components/schemas/View'
required:
- outcome
- comparison
- session