暂存
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s

This commit is contained in:
2026-08-06 09:35:00 +08:00
parent 8659dfc658
commit 88cc5e96ec
75 changed files with 6520 additions and 513 deletions

View File

@@ -12,9 +12,11 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -77,11 +79,15 @@ type SeriesRunner struct {
observation *cardapp.Service
integration *integrationlog.Repository
carrier *postgres.CarrierStore
auditWriter *audit.Writer
}
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository, auditWriter *audit.Writer) *SeriesRunner {
return &SeriesRunner{
db: db, gateway: gatewayClient, observation: observation, integration: integration,
carrier: postgres.NewCarrierStore(db), auditWriter: auditWriter,
}
}
// Provider 返回用于请求互斥的运营商接入标识。
@@ -188,7 +194,7 @@ func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.Series
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)
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response, attempt.IntegrationID)
}
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
@@ -222,7 +228,7 @@ func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.S
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp, integrationID string) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
@@ -236,21 +242,19 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
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) ||
deviceChanged := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
device.SwitchMode != string(response.SwitchMode)
changed := deviceChanged
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
state, err := loadDeviceObservationState(ctx, tx, device.ID, int(response.CurrentSlotNo))
if err != nil {
return err
}
beforeSlot := bindingSlot(state.current)
afterSlot := bindingSlot(state.target)
changed = deviceChanged || beforeSlot != int(response.CurrentSlotNo)
auditChanged := deviceChanged || beforeSlot != afterSlot
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
@@ -259,19 +263,192 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
if int(response.CurrentSlotNo) > 0 {
if err := 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; err != nil {
return err
}
}
if !auditChanged {
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
return r.appendDeviceObservationAudit(ctx, tx, device, state, beforeSlot, afterSlot, integrationID, map[string]any{
"online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
"switch_mode": device.SwitchMode, "current_slot": beforeSlot,
}, map[string]any{
"online_status": int(response.OnlineStatus), "software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode), "current_slot": afterSlot,
})
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
wrapped := apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
r.recordDeviceObservationFailure(ctx, device, integrationID, wrapped)
return false, wrapped
}
return changed, nil
}
type deviceObservationState struct {
bindings []model.DeviceSimBinding
cards map[uint]*model.IotCard
current *model.DeviceSimBinding
target *model.DeviceSimBinding
}
func loadDeviceObservationState(ctx context.Context, tx *gorm.DB, deviceID uint, targetSlot int) (*deviceObservationState, error) {
state := &deviceObservationState{cards: make(map[uint]*model.IotCard)}
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).
Order("slot_position ASC").Find(&state.bindings).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备有效卡槽失败")
}
cardIDs := make([]uint, 0, len(state.bindings))
for index := range state.bindings {
binding := &state.bindings[index]
cardIDs = append(cardIDs, binding.IotCardID)
if binding.IsCurrent && state.current == nil {
state.current = binding
}
if targetSlot > 0 && binding.SlotPosition == targetSlot {
state.target = binding
}
}
if len(cardIDs) == 0 {
return state, nil
}
var cards []*model.IotCard
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备卡槽关联 IoT 卡失败")
}
for _, card := range cards {
state.cards[card.ID] = card
}
return state, nil
}
func bindingSlot(binding *model.DeviceSimBinding) int {
if binding == nil {
return 0
}
return binding.SlotPosition
}
func (r *SeriesRunner) appendDeviceObservationAudit(
ctx context.Context,
tx *gorm.DB,
device *model.Device,
state *deviceObservationState,
beforeSlot, afterSlot int,
integrationID string,
beforeData, afterData map[string]any,
) error {
if r.auditWriter == nil {
return apperrors.New(apperrors.CodeInternalError, "设备观测统一审计能力未配置")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "Worker 同步设备观测事实",
}}
if beforeSlot != afterSlot {
resources = appendDeviceObservationBindingResources(resources, device, state.current, state.cards[stateCardID(state.current)], false,
constants.AuditResourceRoleDeviceOldCurrentCard, constants.AuditResourceRoleDeviceOldCurrentBinding)
resources = appendDeviceObservationBindingResources(resources, device, state.target, state.cards[stateCardID(state.target)], true,
constants.AuditResourceRoleDeviceNewCurrentCard, constants.AuditResourceRoleDeviceNewCurrentBinding)
}
resources = append(resources, deviceObservationIntegrationResource(ctx, device, integrationID))
return r.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实", ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, Resources: resources,
})
}
func appendDeviceObservationBindingResources(
resources []audit.ResourceInput,
device *model.Device,
binding *model.DeviceSimBinding,
card *model.IotCard,
afterCurrent bool,
cardRole, bindingRole string,
) []audit.ResourceInput {
if binding == nil {
return resources
}
if card != nil {
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: cardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备当前卡槽已同步",
})
}
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
identity := map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": device.VirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID, "is_current": afterCurrent,
}
if card != nil {
identity["iccid"] = card.ICCID
identity["virtual_no"] = card.VirtualNo
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: bindingRole,
IdentitySnapshot: identity,
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func stateCardID(binding *model.DeviceSimBinding) uint {
if binding == nil {
return 0
}
return binding.IotCardID
}
func deviceObservationIntegrationResource(ctx context.Context, device *model.Device, integrationID string) audit.ResourceInput {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
return audit.ResourceInput{
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleWorkerIntegration,
IdentitySnapshot: map[string]any{
"integration_id": integrationID, "provider": constants.IntegrationProviderGateway,
"direction": constants.IntegrationDirectionOutbound, "operation": constants.IntegrationOperationGatewayDeviceInfo,
"resource_type": constants.CardObservationResourceTypeDevice, "resource_id": deviceID,
"resource_key": "device:" + deviceID, "correlation_id": auditcontext.From(ctx).CorrelationID,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
func (r *SeriesRunner) recordDeviceObservationFailure(ctx context.Context, device *model.Device, integrationID string, businessErr error) {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
r.auditWriter.RecordFailure(ctx, r.db, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实失败", ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
BeforeData: map[string]any{
"online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
"switch_mode": device.SwitchMode,
},
SubjectVisibility: constants.AuditSubjectResult,
}, deviceObservationIntegrationResource(ctx, device, integrationID)},
}, businessErr)
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")