收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
@@ -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(¤tBinding).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(¤t).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)
|
||||
|
||||
Reference in New Issue
Block a user