收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -37,14 +37,20 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
resourceID := payload.ResourceID
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
_, err := l.repository.Start(ctx, integrationlog.Attempt{
attempt, err := l.repository.Start(ctx, integrationlog.Attempt{
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID,
Attempt: payload.Attempt, InitialResult: initialResult(result), RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
if err != nil || result != constants.IntegrationResultFailed {
return err
}
_, err = l.repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ResponseSummary: map[string]any{"reason": reason},
})
return err
}
@@ -80,6 +86,12 @@ func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *ca
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
if payload.ResourceType == constants.CardObservationResourceTypeDevice {
if _, err := r.loadDevice(ctx, payload); err != nil {
return "", err
}
return constants.CardObservationResourceTypeDevice, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
@@ -97,6 +109,18 @@ func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.Serie
if expected == "" {
return false, nil
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
currentICCIDs, err := r.loadCurrentICCIDs(ctx, payload)
if err != nil {
return false, err
}
for _, currentICCID := range currentICCIDs {
if strings.EqualFold(expected, strings.TrimSpace(currentICCID)) {
return true, nil
}
}
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
@@ -120,6 +144,9 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
return r.runDeviceInfo(ctx, payload)
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
@@ -148,6 +175,178 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
return result, runErr
}
func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startDeviceAttempt(ctx, payload, device)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{}
if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response)
}
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
completionResult = constants.IntegrationResultFailed
if isRateLimited(runErr) {
completionResult = constants.IntegrationResultRateLimited
result.RateLimited = true
}
}
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
})
if completeErr != nil {
return result, completeErr
}
return result, runErr
}
func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, device *model.Device) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "device:" + strconv.FormatUint(uint64(device.ID), 10)
return r.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
RequestSummary: map[string]any{"device_id": device.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
now := time.Now()
updates := map[string]any{
"online_status": int(response.OnlineStatus),
"software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode),
"last_gateway_sync_at": now,
}
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
var currentBinding struct {
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
return nil
}
return tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
}
return changed, nil
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")
}
deviceID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || deviceID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备观测资源 ID 无效")
}
var device model.Device
if err := r.db.WithContext(ctx).Where("id = ?", uint(deviceID)).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "设备不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备观测本地快照失败")
}
if deviceGatewayIdentifier(&device) == "" {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备缺少 Gateway 标识")
}
return &device, nil
}
func (r *SeriesRunner) loadCurrentICCIDs(ctx context.Context, payload cardapp.SeriesTaskPayload) ([]string, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return nil, err
}
var current struct {
ICCID string
ICCID19 string
ICCID20 *string
}
err = r.db.WithContext(ctx).Table("tb_device_sim_binding AS binding").
Select("card.iccid, card.iccid_19, card.iccid_20").
Joins("JOIN tb_iot_card AS card ON card.id = binding.iot_card_id AND card.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ? AND binding.is_current = ? AND binding.deleted_at IS NULL", device.ID, constants.BindStatusBound, true).
Take(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备当前卡失败")
}
iccids := []string{current.ICCID, current.ICCID19}
if current.ICCID20 != nil {
iccids = append(iccids, *current.ICCID20)
}
return iccids, nil
}
func deviceGatewayIdentifier(device *model.Device) string {
if strings.TrimSpace(device.IMEI) != "" {
return strings.TrimSpace(device.IMEI)
}
return strings.TrimSpace(device.SN)
}
func parseGatewayTime(raw gateway.FlexString) *time.Time {
value := strings.TrimSpace(string(raw))
if value == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return &parsed
}
timestamp, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil
}
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
parsed := time.Unix(timestamp, 0)
return &parsed
}
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "card:" + formatCardID(card.ID)
@@ -235,7 +434,7 @@ func operationForSyncType(syncType string) string {
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
return constants.IntegrationOperationGatewayDeviceInfo
}
}
@@ -255,5 +454,12 @@ func optionalText(value string) *string {
return &value
}
func initialResult(result string) string {
if result == constants.IntegrationResultFailed {
return constants.IntegrationResultPending
}
return result
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)