feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -21,6 +21,10 @@ type accessUnresolvedIdentityStore interface {
|
||||
AccessUnresolvedIdentities(context.Context, AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error)
|
||||
}
|
||||
|
||||
type accessIdentityClaimStore interface {
|
||||
ClaimAccessIdentity(context.Context, string, AccessIdentityClaimInput) (AccessIdentityClaimResult, error)
|
||||
}
|
||||
|
||||
func defaultAccessThresholds(now time.Time) AccessThresholdConfig {
|
||||
return AccessThresholdConfig{
|
||||
Version: 1,
|
||||
@@ -114,6 +118,42 @@ func (s *Service) AccessUnresolvedIdentities(ctx context.Context, query AccessUn
|
||||
return store.AccessUnresolvedIdentities(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
identityID = strings.TrimSpace(identityID)
|
||||
if identityID == "" || len(identityID) > 128 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"}
|
||||
}
|
||||
input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if !validAccessClaimVIN(input.VIN) {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"}
|
||||
}
|
||||
input.Note = strings.TrimSpace(input.Note)
|
||||
if len([]rune(input.Note)) > 500 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_NOTE_TOO_LONG", Message: "认领说明不能超过 500 个字"}
|
||||
}
|
||||
input.Actor = strings.TrimSpace(input.Actor)
|
||||
if input.Actor == "" {
|
||||
input.Actor = "platform-admin"
|
||||
}
|
||||
store, ok := s.store.(accessIdentityClaimStore)
|
||||
if !ok {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_READ_ONLY", Message: "当前存储不支持来源身份认领"}
|
||||
}
|
||||
return store.ClaimAccessIdentity(ctx, identityID, input)
|
||||
}
|
||||
|
||||
func validAccessClaimVIN(vin string) bool {
|
||||
if len(vin) < 6 || len(vin) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range vin {
|
||||
if (char < 'A' || char > 'Z') && (char < '0' || char > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessSummary, error) {
|
||||
rows, config, err := s.accessRows(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -61,6 +61,102 @@ GREATEST(0,TIMESTAMPDIFF(SECOND,r.latest_seen_at,NOW())),
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
if err := s.ensureAccessSchema(ctx); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var phone, sourcePlate, manufacturer string
|
||||
err = tx.QueryRowContext(ctx, `SELECT r.phone,COALESCE(r.plate,''),COALESCE(r.manufacturer,'')
|
||||
FROM jt808_registration r
|
||||
LEFT JOIN vehicle_identity_binding b ON b.phone=r.phone
|
||||
WHERE SHA2(CONCAT('JT808:',r.phone),256)=?
|
||||
AND COALESCE(NULLIF(TRIM(b.vin),''),'')=''
|
||||
AND (r.vin IS NULL OR TRIM(r.vin)='' OR LOWER(TRIM(r.vin))='unknown')
|
||||
LIMIT 1 FOR UPDATE`, identityID).Scan(&phone, &sourcePlate, &manufacturer)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
var targetPlate, currentPhone string
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(plate,''),COALESCE(phone,'') FROM vehicle_identity_binding WHERE BINARY vin=BINARY ? FOR UPDATE`, input.VIN).Scan(&targetPlate, ¤tPhone)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if currentPhone != "" && currentPhone != phone {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_TARGET_CONFLICT", Message: "所选车辆已绑定其他 JT808 终端,请先核对换绑关系"}
|
||||
}
|
||||
|
||||
var conflictingVIN string
|
||||
err = tx.QueryRowContext(ctx, `SELECT vin FROM vehicle_identity_binding WHERE phone=? AND BINARY vin<>BINARY ? LIMIT 1 FOR UPDATE`, phone, input.VIN).Scan(&conflictingVIN)
|
||||
if err == nil {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_SOURCE_CONFLICT", Message: "该来源已绑定其他车辆,请刷新待办后核对"}
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_identity_binding SET phone=?,updated_at=CURRENT_TIMESTAMP WHERE BINARY vin=BINARY ?`, phone, input.VIN); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE jt808_registration SET vin=? WHERE phone=?`, input.VIN, phone); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
profileValues := make([]string, 7)
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(brand_name,''),COALESCE(model_name,''),COALESCE(vehicle_type,''),COALESCE(company_name,''),COALESCE(operation_status,''),COALESCE(access_provider,''),COALESCE(DATE_FORMAT(first_access_at,'%Y-%m-%d %H:%i:%s'),'') FROM vehicle_profile WHERE BINARY vin=BINARY ?`, input.VIN).Scan(
|
||||
&profileValues[0], &profileValues[1], &profileValues[2], &profileValues[3], &profileValues[4], &profileValues[5], &profileValues[6],
|
||||
)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
missingLabels := []string{"车辆品牌", "车型", "车辆类型", "所属企业", "运营状态", "接入服务商", "首次接入"}
|
||||
missing := make([]string, 0, len(missingLabels))
|
||||
for index, label := range missingLabels {
|
||||
if strings.TrimSpace(profileValues[index]) == "" || (index == 4 && strings.EqualFold(profileValues[index], "unknown")) {
|
||||
missing = append(missing, label)
|
||||
}
|
||||
}
|
||||
note := input.Note
|
||||
if note == "" {
|
||||
note = "核对来源标识、车牌与厂家后绑定权威 VIN"
|
||||
}
|
||||
claimedAt := time.Now().Format(time.RFC3339)
|
||||
audit, err := tx.ExecContext(ctx, `INSERT INTO vehicle_access_identity_audit
|
||||
(identity_id,protocol,identifier_hash,identifier_masked,source_plate,manufacturer,vin,actor,note,claimed_at)
|
||||
VALUES (?,'JT808',?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`, identityID, identityID, maskAccessIdentifier(phone), sourcePlate, manufacturer, input.VIN, input.Actor, note)
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
auditID, _ := audit.LastInsertId()
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identityID, Protocol: "JT808", IdentifierMasked: maskAccessIdentifier(phone), VIN: input.VIN,
|
||||
Plate: targetPlate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: claimedAt, AuditID: auditID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func maskAccessIdentifier(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 7 {
|
||||
return "***"
|
||||
}
|
||||
return value[:3] + "****" + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
v.vin,
|
||||
@@ -166,6 +262,22 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_threshold_audit_version (version),
|
||||
KEY idx_access_threshold_audit_changed (changed_at)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
identity_id VARCHAR(128) NOT NULL,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
identifier_hash VARCHAR(128) NOT NULL,
|
||||
identifier_masked VARCHAR(64) NOT NULL,
|
||||
source_plate VARCHAR(64) NOT NULL,
|
||||
manufacturer VARCHAR(128) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
actor VARCHAR(128) NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
claimed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_identity_vin (vin),
|
||||
KEY idx_access_identity_claimed (claimed_at),
|
||||
UNIQUE KEY uk_access_identity_claim (identity_id)
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
|
||||
@@ -212,6 +212,60 @@ func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAccessIdentityBindsExistingVehicleAndReturnsProfileFollowUp(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
result, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426", Actor: "access-admin", Note: "设备交付单核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("claim access identity: %v", err)
|
||||
}
|
||||
if result.VIN != "LB9A32A24R0LS1426" || result.Plate != "粤AG18312" || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("claim result lost identity evidence: %+v", result)
|
||||
}
|
||||
if !result.ProfileComplete || len(result.ProfileMissingFields) != 0 || result.AuditID == 0 || result.ClaimedBy != "access-admin" {
|
||||
t.Fatalf("claim result must expose profile and audit state: %+v", result)
|
||||
}
|
||||
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "JT808", Limit: 20})
|
||||
if err != nil || page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("claimed identity must leave the pending queue: page=%+v err=%v", page, err)
|
||||
}
|
||||
if _, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426"}); err == nil {
|
||||
t.Fatal("repeated claim must fail as stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionClaimAccessIdentityUsesLockedBindingAndImmutableAudit(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)INSERT IGNORE INTO vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)SELECT r\.phone.*SHA2\(CONCAT\('JT808:',r\.phone\),256\)=\?.*FOR UPDATE`).WithArgs("identity-hash").WillReturnRows(sqlmock.NewRows([]string{"phone", "plate", "manufacturer"}).AddRow("13800000001", "粤A00001", "测试终端"))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(plate,''\),COALESCE\(phone,''\).*vehicle_identity_binding.*FOR UPDATE`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"plate", "phone"}).AddRow("粤A00001", ""))
|
||||
mock.ExpectQuery(`(?s)SELECT vin FROM vehicle_identity_binding WHERE phone=\?.*FOR UPDATE`).WithArgs("13800000001", "VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}))
|
||||
mock.ExpectExec(`UPDATE vehicle_identity_binding SET phone=\?,updated_at=CURRENT_TIMESTAMP`).WithArgs("13800000001", "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE jt808_registration SET vin=\? WHERE phone=\?`).WithArgs("VIN001", "13800000001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(brand_name,''\).*FROM vehicle_profile`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"brand", "model", "type", "company", "status", "provider", "first_access"}).AddRow("品牌", "车型", "乘用车", "测试企业", "active", "测试服务商", "2026-07-01 08:00:00"))
|
||||
mock.ExpectExec(`(?s)INSERT INTO vehicle_access_identity_audit`).WithArgs("identity-hash", "identity-hash", "138****0001", "粤A00001", "测试终端", "VIN001", "access-admin", "核对通过").WillReturnResult(sqlmock.NewResult(42, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := store.ClaimAccessIdentity(t.Context(), "identity-hash", AccessIdentityClaimInput{VIN: "VIN001", Actor: "access-admin", Note: "核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("production claim: %v", err)
|
||||
}
|
||||
if result.AuditID != 42 || !result.ProfileComplete || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("production claim result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertStore interface {
|
||||
@@ -13,17 +15,26 @@ type alertStore interface {
|
||||
AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error)
|
||||
AlertEvent(context.Context, string) (AlertEvent, error)
|
||||
AlertRules(context.Context) ([]AlertRule, error)
|
||||
AlertRulePage(context.Context, AlertRuleQuery) (AlertRulePage, error)
|
||||
AlertRuleRevisions(context.Context, string) ([]AlertRuleRevision, error)
|
||||
SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error)
|
||||
SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error)
|
||||
SetAlertRuleArchived(context.Context, string, bool, AlertRuleLifecycleRequest) (AlertRule, error)
|
||||
ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error)
|
||||
AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error)
|
||||
MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error)
|
||||
RetryAlertNotification(context.Context, int64, AlertNotificationRetryRequest) (AlertNotificationRetryResult, error)
|
||||
AlertNotificationRetryAudits(context.Context, int64) ([]AlertNotificationRetryAudit, error)
|
||||
}
|
||||
|
||||
type alertEvaluatorStore interface {
|
||||
EvaluateAlerts(context.Context) (AlertEvaluationResult, error)
|
||||
}
|
||||
|
||||
type alertNotificationHealthStore interface {
|
||||
AlertNotificationDeliveryHealth(context.Context) (AlertNotificationDeliveryHealth, error)
|
||||
}
|
||||
|
||||
func (s *Service) alertStore() (alertStore, error) {
|
||||
store, ok := s.store.(alertStore)
|
||||
if !ok {
|
||||
@@ -58,7 +69,14 @@ func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[Alert
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
return store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
page, err := store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
for index := range page.Items {
|
||||
page.Items[index] = normalizeVehicleEvent(page.Items[index])
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
|
||||
@@ -69,7 +87,11 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
event, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
@@ -80,7 +102,176 @@ func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return store.AlertRules(ctx)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleQuery(query AlertRuleQuery) AlertRuleQuery {
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
if len([]rune(query.Keyword)) > 160 {
|
||||
query.Keyword = string([]rune(query.Keyword)[:160])
|
||||
}
|
||||
query.Protocol = strings.TrimSpace(query.Protocol)
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
if query.Status != "enabled" && query.Status != "disabled" {
|
||||
query.Status = "all"
|
||||
}
|
||||
query.Lifecycle = strings.ToLower(strings.TrimSpace(query.Lifecycle))
|
||||
if query.Lifecycle != "archived" {
|
||||
query.Lifecycle = "current"
|
||||
}
|
||||
if query.Limit != 20 && query.Limit != 50 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Offset = query.Offset / query.Limit * query.Limit
|
||||
return query
|
||||
}
|
||||
|
||||
func (s *Service) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
return store.AlertRulePage(ctx, normalizeAlertRuleQuery(query))
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationConfig(ctx context.Context) (AlertNotificationConfig, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationConfig{}, err
|
||||
}
|
||||
return NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
store, ok := s.store.(alertNotificationHealthStore)
|
||||
if !ok {
|
||||
return AlertNotificationDeliveryHealth{}, fmt.Errorf("store does not provide notification delivery health")
|
||||
}
|
||||
health, err := store.AlertNotificationDeliveryHealth(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
configured := map[string]AlertNotificationChannelCapability{}
|
||||
for _, channel := range config.Channels {
|
||||
configured[channel.Channel] = channel
|
||||
}
|
||||
byChannel := map[string]AlertNotificationChannelHealth{}
|
||||
for _, channel := range health.Channels {
|
||||
byChannel[channel.Channel] = channel
|
||||
}
|
||||
health.Channels = make([]AlertNotificationChannelHealth, 0, len(config.Channels))
|
||||
for _, capability := range config.Channels {
|
||||
channel := byChannel[capability.Channel]
|
||||
channel.Channel = capability.Channel
|
||||
channel.Label = capability.Label
|
||||
channel.Configured = capability.Configured
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
if health.AsOf == "" {
|
||||
health.AsOf = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertRuleRevisions(ctx, id)
|
||||
}
|
||||
|
||||
func alertRuleInputFromRule(rule AlertRule) AlertRuleInput {
|
||||
return AlertRuleInput{
|
||||
ID: rule.ID, Name: rule.Name, Description: rule.Description, TriggerType: rule.TriggerType,
|
||||
FenceName: rule.FenceName, FenceLongitude: rule.FenceLongitude, FenceLatitude: rule.FenceLatitude, FenceRadiusM: rule.FenceRadiusM,
|
||||
Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator,
|
||||
Threshold: rule.Threshold, ThresholdHigh: rule.ThresholdHigh, BooleanThreshold: rule.BooleanThreshold,
|
||||
DurationSec: rule.DurationSec, RecoveryOperator: rule.RecoveryOperator, RecoveryThreshold: rule.RecoveryThreshold,
|
||||
RepeatIntervalSec: rule.RepeatIntervalSec, ScopeProtocols: append([]string(nil), rule.ScopeProtocols...), ScopeVINs: append([]string(nil), rule.ScopeVINs...),
|
||||
ScopeOEMs: append([]string(nil), rule.ScopeOEMs...), ScopeModels: append([]string(nil), rule.ScopeModels...), ScopeCompanies: append([]string(nil), rule.ScopeCompanies...),
|
||||
NotificationChannels: append([]string(nil), rule.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), rule.NotificationTargets...), Enabled: rule.Enabled, Version: rule.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func alertRuleConfigurationSignature(input AlertRuleInput) string {
|
||||
input.Version = 0
|
||||
input.Actor = ""
|
||||
input.AuditAction = ""
|
||||
value, _ := json.Marshal(input)
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func (s *Service) RollbackAlertRule(ctx context.Context, id string, request AlertRuleRollbackRequest) (AlertRule, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || request.TargetVersion <= 0 || request.CurrentVersion <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_VERSION_REQUIRED", Message: "自动化 ID、当前版本和目标版本不能为空"}
|
||||
}
|
||||
if request.TargetVersion == request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_TARGET_INVALID", Message: "目标版本必须与当前版本不同"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rules, err := store.AlertRules(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var current *AlertRule
|
||||
for index := range rules {
|
||||
if rules[index].ID == id {
|
||||
current = &rules[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
if current.Version != request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
revisions, err := store.AlertRuleRevisions(ctx, id)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var target *AlertRule
|
||||
for index := range revisions {
|
||||
if revisions[index].Version == request.TargetVersion {
|
||||
target = &revisions[index].Snapshot
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_REVISION_NOT_FOUND", Message: "目标版本不存在或已被清理"}
|
||||
}
|
||||
next := alertRuleInputFromRule(*target)
|
||||
next.ID = id
|
||||
next.Version = current.Version
|
||||
next.Enabled = current.Enabled
|
||||
next.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
next.AuditAction = "rollback"
|
||||
if alertRuleConfigurationSignature(next) == alertRuleConfigurationSignature(alertRuleInputFromRule(*current)) {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_NO_CHANGES", Message: "目标版本与当前配置相同,无需恢复"}
|
||||
}
|
||||
return s.SaveAlertRule(ctx, next)
|
||||
}
|
||||
|
||||
func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
normalizeAlertRuleTrigger(&input)
|
||||
definitions, err := s.metricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
@@ -105,7 +296,12 @@ func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (Aler
|
||||
if err := validateAlertRuleScopes(input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err := s.validateAlertNotificationTargets(input.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.EqualFold(input.Operator, "changed") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
@@ -144,6 +340,9 @@ func validateAlertRuleScopes(input AlertRuleInput) error {
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.TrimSpace(id) == "" || update.Version <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"}
|
||||
}
|
||||
@@ -155,6 +354,40 @@ func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update Ale
|
||||
return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleLifecycleRequest(request AlertRuleLifecycleRequest) (AlertRuleLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 200 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 200 字"}
|
||||
}
|
||||
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
normalized, err := normalizeAlertRuleLifecycleRequest(request)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return store.SetAlertRuleArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
if strings.TrimSpace(id) == "" || request.Version <= 0 {
|
||||
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"}
|
||||
@@ -171,7 +404,73 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
||||
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
||||
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
||||
case "processing":
|
||||
event.ExecutionState = "processing"
|
||||
case "recovered":
|
||||
event.ExecutionState = "recovered"
|
||||
case "closed":
|
||||
event.ExecutionState = "completed"
|
||||
case "ignored":
|
||||
event.ExecutionState = "ignored"
|
||||
default:
|
||||
event.ExecutionState = "pending"
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func canonicalVehicleEvent(triggerType, metric, operator string) (string, string) {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
metric = strings.ToLower(strings.TrimSpace(metric))
|
||||
operator = strings.ToLower(strings.TrimSpace(operator))
|
||||
if triggerType == "geofence" {
|
||||
action := map[string]string{"enter": "entered", "exit": "exited", "inside": "inside", "outside": "outside"}[operator]
|
||||
if action == "" {
|
||||
action = "changed"
|
||||
}
|
||||
return "geofence", "vehicle.geofence." + action
|
||||
}
|
||||
if triggerType == "offline" || metric == "freshness_sec" {
|
||||
return "connectivity", "vehicle.connectivity.offline"
|
||||
}
|
||||
if triggerType == "stationary" {
|
||||
return "telemetry", "vehicle.motion.stationary"
|
||||
}
|
||||
switch metric {
|
||||
case "soc_percent":
|
||||
if operator == "lt" || operator == "lte" {
|
||||
return "telemetry", "vehicle.telemetry.soc_low"
|
||||
}
|
||||
case "speed_kmh":
|
||||
if operator == "gt" || operator == "gte" {
|
||||
return "telemetry", "vehicle.motion.speed_high"
|
||||
}
|
||||
case "alarm_active":
|
||||
return "safety", "vehicle.safety.alarm_activated"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "safety", "vehicle.safety.hydrogen_concentration_high"
|
||||
case "daily_mileage_km":
|
||||
return "business", "vehicle.mileage.daily_completed"
|
||||
}
|
||||
if strings.Contains(metric, "hydrogen") {
|
||||
return "safety", "vehicle.safety." + metric
|
||||
}
|
||||
if strings.Contains(metric, "mileage") || strings.HasPrefix(metric, "daily_") {
|
||||
return "business", "vehicle.business." + metric
|
||||
}
|
||||
if metric == "" {
|
||||
metric = "changed"
|
||||
}
|
||||
return "telemetry", "vehicle.telemetry." + metric
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
|
||||
@@ -184,6 +483,23 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Search = strings.TrimSpace(query.Search)
|
||||
searchRunes := []rune(query.Search)
|
||||
if len(searchRunes) > 160 {
|
||||
query.Search = string(searchRunes[:160])
|
||||
}
|
||||
query.DeliveryStatus = strings.ToLower(strings.TrimSpace(query.DeliveryStatus))
|
||||
switch query.DeliveryStatus {
|
||||
case "", "failed", "queued", "delivered":
|
||||
default:
|
||||
return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
query.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
@@ -196,6 +512,12 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
|
||||
return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"}
|
||||
}
|
||||
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
request.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(request.AllowedVINs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -203,6 +525,106 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
|
||||
return store.MarkAlertNotificationsRead(ctx, request)
|
||||
}
|
||||
|
||||
func alertNotificationAllowedVINs(principal Principal) []string {
|
||||
seen := map[string]struct{}{}
|
||||
allowed := make([]string, 0, len(principal.VehicleVINs)+len(principal.VehicleGrants))
|
||||
for _, value := range principal.VehicleVINs {
|
||||
vin := strings.ToUpper(strings.TrimSpace(value))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
for _, grant := range principal.VehicleGrants {
|
||||
vin := strings.ToUpper(strings.TrimSpace(grant.VIN))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func alertNotificationRetryActor(ctx context.Context) (string, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_AUTH_REQUIRED", Message: "通知重试需要已认证的操作账号"}
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(principal.Role))
|
||||
if role != "admin" && role != "operator" {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_FORBIDDEN", Message: "当前账号无权重新投递通知"}
|
||||
}
|
||||
return firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "platform-operator"), nil
|
||||
}
|
||||
|
||||
func validAlertNotificationRetryKey(value string) bool {
|
||||
if len(value) < 16 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
actor, err := alertNotificationRetryActor(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
reasonRunes := []rune(request.Reason)
|
||||
if len(reasonRunes) < 4 || len(reasonRunes) > 200 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_REASON_INVALID", Message: "请填写 4 到 200 个字符的重试原因"}
|
||||
}
|
||||
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
|
||||
if !validAlertNotificationRetryKey(request.IdempotencyKey) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_INVALID", Message: "重试请求键无效,请重新打开确认窗口"}
|
||||
}
|
||||
if request.ExpectedAttemptCount <= 0 || request.ExpectedAttemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_INVALID", Message: "通知尝试次数无效或已经达到上限"}
|
||||
}
|
||||
request.Actor = actor
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
return store.RetryAlertNotification(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertNotificationRetryAudits(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
|
||||
store, ok := s.store.(alertEvaluatorStore)
|
||||
if !ok {
|
||||
@@ -237,6 +659,10 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if severity != "critical" && severity != "major" && severity != "minor" {
|
||||
return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"}
|
||||
}
|
||||
triggerType := normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
if triggerType != "metric" && triggerType != "geofence" && triggerType != "stationary" && triggerType != "offline" {
|
||||
return clientError{Code: "ALERT_RULE_TRIGGER_TYPE_INVALID", Message: "自动化触发类型无效"}
|
||||
}
|
||||
valueType := strings.ToLower(strings.TrimSpace(input.ValueType))
|
||||
if valueType != "numeric" && valueType != "boolean" {
|
||||
return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"}
|
||||
@@ -244,6 +670,37 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if strings.TrimSpace(input.Metric) == "" {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
if triggerType == "geofence" {
|
||||
if len(input.ScopeProtocols) != 1 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_PROTOCOL_REQUIRED", Message: "电子围栏必须指定唯一定位协议,避免多数据源坐标漂移或重复触发"}
|
||||
}
|
||||
if valueType != "numeric" || !strings.EqualFold(input.Metric, "geofence_distance_m") {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_TYPE_INVALID", Message: "电子围栏规则配置不完整"}
|
||||
}
|
||||
if operator != "enter" && operator != "exit" && operator != "inside" && operator != "outside" {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_MODE_INVALID", Message: "电子围栏仅支持进入、离开、围栏内或围栏外"}
|
||||
}
|
||||
if strings.TrimSpace(input.FenceName) == "" || len([]rune(strings.TrimSpace(input.FenceName))) > 80 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_NAME_INVALID", Message: "围栏名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
if input.FenceLongitude < -180 || input.FenceLongitude > 180 || input.FenceLatitude < -90 || input.FenceLatitude > 90 || (input.FenceLongitude == 0 && input.FenceLatitude == 0) {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_CENTER_INVALID", Message: "围栏中心坐标无效"}
|
||||
}
|
||||
if input.FenceRadiusM < 50 || input.FenceRadiusM > 100000 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_RADIUS_INVALID", Message: "围栏半径须在 50 米到 100 公里之间"}
|
||||
}
|
||||
if (operator == "enter" || operator == "exit") && input.DurationSec != 0 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_DURATION_INVALID", Message: "进入或离开围栏按状态变化立即触发,不能配置持续时间"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "重复间隔超出允许范围"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var metric *MetricDefinition
|
||||
definitions := catalog
|
||||
if len(definitions) == 0 {
|
||||
@@ -259,13 +716,12 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if metric == nil || !metric.Alertable {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
|
||||
}
|
||||
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
|
||||
if !alertMetricDefinitionSupported(*metric) {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"}
|
||||
}
|
||||
if metric.ValueType != valueType {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true}
|
||||
if !allowed[operator] {
|
||||
return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"}
|
||||
@@ -279,15 +735,81 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil {
|
||||
return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"}
|
||||
}
|
||||
if triggerType == "stationary" && (!strings.EqualFold(input.Metric, "speed_kmh") || (operator != "lt" && operator != "lte") || input.Threshold < 0 || input.Threshold > 10 || input.DurationSec < 300) {
|
||||
return clientError{Code: "ALERT_RULE_STATIONARY_INVALID", Message: "长时间静止须使用 0–10 km/h 的速度上限且持续至少 5 分钟"}
|
||||
}
|
||||
if triggerType == "offline" && (!strings.EqualFold(input.Metric, "freshness_sec") || (operator != "gt" && operator != "gte") || input.Threshold < 60) {
|
||||
return clientError{Code: "ALERT_RULE_OFFLINE_INVALID", Message: "长时间离线须配置至少 60 秒的离线阈值"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedAlertTriggerType(triggerType, metric string) string {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
if triggerType != "" {
|
||||
return triggerType
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") {
|
||||
return "offline"
|
||||
}
|
||||
return "metric"
|
||||
}
|
||||
|
||||
func normalizeAlertRuleTrigger(input *AlertRuleInput) {
|
||||
input.TriggerType = normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
switch input.TriggerType {
|
||||
case "geofence":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "geofence_distance_m"
|
||||
input.Threshold = input.FenceRadiusM
|
||||
input.ThresholdHigh = 0
|
||||
input.BooleanThreshold = nil
|
||||
input.RecoveryOperator = ""
|
||||
input.RecoveryThreshold = 0
|
||||
if strings.EqualFold(input.Operator, "enter") || strings.EqualFold(input.Operator, "exit") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
case "stationary":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "speed_kmh"
|
||||
if input.Operator != "lt" && input.Operator != "lte" {
|
||||
input.Operator = "lte"
|
||||
}
|
||||
input.RecoveryOperator = "gt"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
case "offline":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "freshness_sec"
|
||||
if input.Operator != "gt" && input.Operator != "gte" {
|
||||
input.Operator = "gt"
|
||||
}
|
||||
input.DurationSec = 0
|
||||
input.RecoveryOperator = "lte"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
}
|
||||
}
|
||||
|
||||
func alertMetricDefinitionSupported(metric MetricDefinition) bool {
|
||||
if _, supported := alertMetricValue(metric.Key, alertEvaluationEvidence{}); supported {
|
||||
return true
|
||||
}
|
||||
for protocol, source := range metric.SourceFields {
|
||||
switch strings.ToUpper(strings.TrimSpace(protocol)) {
|
||||
case "GB32960", "JT808", "YUTONG_MQTT":
|
||||
if strings.TrimSpace(source) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeAlertChannels(values []string) []string {
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
out := make([]string, 0, len(values)+1)
|
||||
@@ -299,12 +821,163 @@ func normalizeAlertChannels(values []string) []string {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
if !seen["in_app"] {
|
||||
out = append([]string{"in_app"}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertNotificationTargetIDValid(value string) bool {
|
||||
if len(value) < 2 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeAlertNotificationTargets(input *AlertRuleInput) error {
|
||||
if len(input.NotificationTargets) > 20 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LIMIT", Message: "单条自动化最多配置 20 个通知目标"}
|
||||
}
|
||||
if len(input.NotificationTargets) == 0 {
|
||||
channels := normalizeAlertChannels(input.NotificationChannels)
|
||||
if len(channels) == 0 {
|
||||
input.NotificationChannels = []string{}
|
||||
input.NotificationTargets = []AlertNotificationTarget{}
|
||||
return nil
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel != "in_app" {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_REQUIRED", Message: "短信、邮件和企业通讯必须选择明确的通知目标"}
|
||||
}
|
||||
}
|
||||
input.NotificationChannels = []string{"in_app"}
|
||||
input.NotificationTargets = []AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
targets := make([]AlertNotificationTarget, 0, len(input.NotificationTargets)+1)
|
||||
seen := map[string]bool{}
|
||||
hasInApp := false
|
||||
for _, target := range input.NotificationTargets {
|
||||
target.Channel = strings.ToLower(strings.TrimSpace(target.Channel))
|
||||
target.RecipientID = strings.TrimSpace(target.RecipientID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !allowed[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_INVALID", Message: "通知渠道无效"}
|
||||
}
|
||||
if !alertNotificationTargetIDValid(target.RecipientID) {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_INVALID", Message: "通知目标标识无效"}
|
||||
}
|
||||
if target.Label == "" || len([]rune(target.Label)) > 80 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LABEL_INVALID", Message: "通知目标名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
key := target.Channel + "\x00" + target.RecipientID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
hasInApp = hasInApp || target.Channel == "in_app"
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if len(targets) > 0 && !hasInApp {
|
||||
targets = append([]AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}, targets...)
|
||||
}
|
||||
channels := make([]string, 0, len(targets))
|
||||
channelSeen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
if !channelSeen[target.Channel] {
|
||||
channelSeen[target.Channel] = true
|
||||
channels = append(channels, target.Channel)
|
||||
}
|
||||
}
|
||||
input.NotificationTargets = targets
|
||||
input.NotificationChannels = channels
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeAlertNotificationConfig(config AlertNotificationConfig) AlertNotificationConfig {
|
||||
allowed := map[string]string{"in_app": "站内信", "sms": "短信", "email": "邮件", "wecom": "企业通讯"}
|
||||
channelConfigured := map[string]bool{"in_app": true}
|
||||
for _, channel := range config.Channels {
|
||||
key := strings.ToLower(strings.TrimSpace(channel.Channel))
|
||||
if _, ok := allowed[key]; ok {
|
||||
channelConfigured[key] = channel.Configured || key == "in_app"
|
||||
}
|
||||
}
|
||||
config.Channels = make([]AlertNotificationChannelCapability, 0, len(allowed))
|
||||
for _, key := range []string{"in_app", "sms", "email", "wecom"} {
|
||||
config.Channels = append(config.Channels, AlertNotificationChannelCapability{Channel: key, Label: allowed[key], Configured: channelConfigured[key]})
|
||||
}
|
||||
targets := make([]AlertNotificationTargetOption, 0, len(config.Targets)+1)
|
||||
seen := map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
target.ID = strings.TrimSpace(target.ID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !alertNotificationTargetIDValid(target.ID) || target.Label == "" || len([]rune(target.Label)) > 80 || seen[target.ID] {
|
||||
continue
|
||||
}
|
||||
channels := make([]string, 0, len(target.Channels))
|
||||
for _, raw := range target.Channels {
|
||||
channel := strings.ToLower(strings.TrimSpace(raw))
|
||||
if _, ok := allowed[channel]; ok && !containsString(channels, channel) {
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[target.ID] = true
|
||||
target.Channels = channels
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if !seen["platform-operators"] {
|
||||
targets = append([]AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}, targets...)
|
||||
}
|
||||
config.Targets = targets
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Service) validateAlertNotificationTargets(targets []AlertNotificationTarget) error {
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
capabilities := map[string]bool{}
|
||||
for _, channel := range config.Channels {
|
||||
capabilities[channel.Channel] = channel.Configured
|
||||
}
|
||||
options := map[string]map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
options[target.ID] = map[string]bool{}
|
||||
for _, channel := range target.Channels {
|
||||
options[target.ID][channel] = true
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
if !capabilities[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_UNAVAILABLE", Message: "所选外部通知渠道尚未配置发送网关"}
|
||||
}
|
||||
if len(options) > 0 && !options[target.RecipientID][target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_UNAVAILABLE", Message: "所选通知目标不支持当前渠道"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanUniqueStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -19,6 +19,7 @@ type alertEvaluationEvidence struct {
|
||||
SpeedKmh, SOCPercent, Longitude, Latitude float64
|
||||
AlarmFlag int64
|
||||
FreshnessSec, DataDelaySec int
|
||||
HasLocation bool
|
||||
}
|
||||
|
||||
type alertCandidateState struct {
|
||||
@@ -49,11 +50,11 @@ const (
|
||||
alertObservationLate
|
||||
)
|
||||
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,trigger_type,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
|
||||
func buildAlertEventInsert(id, fingerprint string, rule AlertRule, item alertEvaluationEvidence, value float64) (string, []any) {
|
||||
return alertEventInsertSQL, []any{
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity,
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity, normalizedAlertTriggerType(rule.TriggerType, rule.Metric),
|
||||
item.VIN, item.Plate, item.Protocol, rule.Metric, rule.Operator, value,
|
||||
rule.Threshold, rule.ThresholdHigh, alertMetricUnit(rule.Metric), rule.DurationSec,
|
||||
item.Location, item.Longitude, item.Latitude, item.SourceEventID,
|
||||
@@ -118,7 +119,7 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertMetricValue(rule.Metric, item)
|
||||
value, supported := alertRuleMetricValue(rule, item)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -138,22 +139,15 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
stateUpserts = append(stateUpserts, alertRuleStateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, LastValue: normalized, ObservedAt: observedAt})
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -193,12 +187,12 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','alert-evaluator','规则命中并达到持续时间')`, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -454,6 +448,7 @@ func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertE
|
||||
if delay.Valid {
|
||||
item.DataDelaySec = int(delay.Int64)
|
||||
}
|
||||
item.HasLocation = validAlertCoordinate(item.Longitude, item.Latitude)
|
||||
items = append(items, item)
|
||||
if len(items) > alertEvaluationVehicleLimit {
|
||||
return nil, fmt.Errorf("alert evaluation evidence exceeds safety limit %d", alertEvaluationVehicleLimit)
|
||||
@@ -506,6 +501,95 @@ func alertMetricValue(metric string, item alertEvaluationEvidence) (float64, boo
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func alertRuleMetricValue(rule AlertRule, item alertEvaluationEvidence) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
if !item.HasLocation || !validAlertCoordinate(rule.FenceLongitude, rule.FenceLatitude) || rule.FenceRadiusM <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return geofenceDistanceMeters(rule.FenceLongitude, rule.FenceLatitude, item.Longitude, item.Latitude), true
|
||||
}
|
||||
return alertMetricValue(rule.Metric, item)
|
||||
}
|
||||
|
||||
func alertRuleMatches(rule AlertRule, value float64, previous alertRuleState, previousExists bool) (bool, float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
inside := value <= rule.FenceRadiusM
|
||||
normalized := 0.0
|
||||
if inside {
|
||||
normalized = 1
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(rule.Operator)) {
|
||||
case "enter":
|
||||
return previousExists && previous.LastValue == 0 && inside, normalized, true
|
||||
case "exit":
|
||||
return previousExists && previous.LastValue != 0 && !inside, normalized, true
|
||||
case "inside":
|
||||
return inside, normalized, true
|
||||
case "outside":
|
||||
return !inside, normalized, true
|
||||
default:
|
||||
return false, normalized, true
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
return previousExists && previous.LastValue != normalized, normalized, true
|
||||
}
|
||||
return compareAlertRuleValue(value, rule), 0, false
|
||||
}
|
||||
|
||||
func validAlertCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
func geofenceDistanceMeters(centerLongitude, centerLatitude, longitude, latitude float64) float64 {
|
||||
const earthRadiusM = 6371008.8
|
||||
toRadians := math.Pi / 180
|
||||
lat1, lat2 := centerLatitude*toRadians, latitude*toRadians
|
||||
dLat := (latitude - centerLatitude) * toRadians
|
||||
dLon := (longitude - centerLongitude) * toRadians
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
func alertNotificationTitle(rule AlertRule) string {
|
||||
if strings.EqualFold(rule.Severity, "critical") {
|
||||
return "【高优先级】" + rule.Name
|
||||
}
|
||||
return rule.Name
|
||||
}
|
||||
|
||||
func alertNotificationContent(rule AlertRule, item alertEvaluationEvidence, value float64, unit string) string {
|
||||
vehicle := firstNonEmpty(item.Plate, item.VIN)
|
||||
switch normalizedAlertTriggerType(rule.TriggerType, rule.Metric) {
|
||||
case "geofence":
|
||||
mode := map[string]string{"enter": "进入", "exit": "离开", "inside": "位于", "outside": "持续位于围栏外"}[strings.ToLower(rule.Operator)]
|
||||
return fmt.Sprintf("%s %s电子围栏“%s”,距中心 %.0f m", vehicle, mode, rule.FenceName, value)
|
||||
case "stationary":
|
||||
return fmt.Sprintf("%s 已低速静止 %s,当前速度 %.2f km/h", vehicle, formatAlertDurationGo(rule.DurationSec), value)
|
||||
case "offline":
|
||||
return fmt.Sprintf("%s 已离线 %s", vehicle, formatAlertDurationGo(int(value)))
|
||||
default:
|
||||
return fmt.Sprintf("%s 触发%s:%.2f %s", vehicle, rule.Name, value, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func formatAlertDurationGo(seconds int) string {
|
||||
if seconds%86400 == 0 && seconds >= 86400 {
|
||||
return fmt.Sprintf("%d 天", seconds/86400)
|
||||
}
|
||||
if seconds%3600 == 0 && seconds >= 3600 {
|
||||
return fmt.Sprintf("%d 小时", seconds/3600)
|
||||
}
|
||||
if seconds%60 == 0 && seconds >= 60 {
|
||||
return fmt.Sprintf("%d 分钟", seconds/60)
|
||||
}
|
||||
return fmt.Sprintf("%d 秒", seconds)
|
||||
}
|
||||
func compareAlertValue(value float64, operator string, threshold float64) bool {
|
||||
switch strings.ToLower(operator) {
|
||||
case "gt":
|
||||
@@ -542,6 +626,30 @@ func alertMetricUnit(metric string) string {
|
||||
return "%"
|
||||
case "freshness_sec", "data_delay_sec":
|
||||
return "秒"
|
||||
case "total_mileage_km":
|
||||
return "km"
|
||||
case "total_voltage_v", "fuel_cell_voltage_v", "max_cell_voltage_v", "min_cell_voltage_v":
|
||||
return "V"
|
||||
case "total_current_a", "fuel_cell_current_a":
|
||||
return "A"
|
||||
case "insulation_kohm":
|
||||
return "kΩ"
|
||||
case "hydrogen_consumption_kg_per_100km":
|
||||
return "kg/100km"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "%"
|
||||
case "hydrogen_pressure_mpa":
|
||||
return "MPa"
|
||||
case "hydrogen_temperature_c", "max_battery_temperature_c", "min_battery_temperature_c":
|
||||
return "℃"
|
||||
case "geofence_distance_m":
|
||||
return "m"
|
||||
case "engine_speed_rpm":
|
||||
return "rpm"
|
||||
case "gnss_satellite_count":
|
||||
return "颗"
|
||||
case "fuel_l":
|
||||
return "L"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package platform
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -11,25 +12,55 @@ func (m *MockStore) seedAlertCenter() {
|
||||
now := time.Now()
|
||||
boolTrue := true
|
||||
m.alertRules = []AlertRule{
|
||||
{ID: "rule-speeding", Name: "持续超速告警", Description: "速度持续高于阈值", Severity: "critical", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 60, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808", "GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "离线超时", Description: "车辆超过阈值未上报", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, DurationSec: 0, RecoveryOperator: "lte", RecoveryThreshold: 300, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "协议告警位", Description: "原始协议告警位非零", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, DurationSec: 0, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-geofence", Name: "车辆驶出运营围栏", Description: "车辆从深圳运营区内移动到围栏外", TriggerType: "geofence", FenceName: "深圳运营区", FenceLongitude: 114.057868, FenceLatitude: 22.543099, FenceRadiusM: 5000, Severity: "critical", ValueType: "numeric", Metric: "geofence_distance_m", Operator: "exit", Threshold: 5000, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 3, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-low-soc", Name: "SOC 低于 20%", Description: "动力电池 SOC 持续低于 20%", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "lt", Threshold: 20, DurationSec: 300, RecoveryOperator: "gte", RecoveryThreshold: 25, RepeatIntervalSec: 3600, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "车辆在线状态处理", Description: "车辆离线时创建事件,恢复上报后自动完成", TriggerType: "offline", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, RecoveryOperator: "lte", RecoveryThreshold: 3600, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-speeding", Name: "车辆急加速", Description: "速度变化率超过运营阈值", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 10, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-mileage", Name: "里程数据完成日结", Description: "记录车辆每日里程结算完成事件", TriggerType: "metric", Severity: "minor", ValueType: "numeric", Metric: "daily_mileage_km", Operator: "gte", Threshold: 0, RepeatIntervalSec: 86400, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-12 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "最高氢浓度超限", Description: "燃料电池系统最高氢浓度超过安全阈值", TriggerType: "metric", Severity: "critical", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
}
|
||||
for i := range m.alertRules {
|
||||
m.alertRules[i].ScopeOEMs = []string{}
|
||||
m.alertRules[i].ScopeModels = []string{}
|
||||
m.alertRules[i].ScopeCompanies = []string{}
|
||||
input := alertRuleInputFromRule(m.alertRules[i])
|
||||
if err := normalizeAlertNotificationTargets(&input); err == nil {
|
||||
m.alertRules[i].NotificationChannels = input.NotificationChannels
|
||||
m.alertRules[i].NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
}
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
for _, rule := range m.alertRules {
|
||||
m.alertRuleRevisions[rule.ID] = []AlertRuleRevision{{RuleID: rule.ID, Version: rule.Version, Actor: rule.UpdatedBy, Action: "create", CreatedAt: rule.UpdatedAt, Snapshot: rule}}
|
||||
}
|
||||
locations := []string{"广东省深圳市南山区科技南路", "广东省广州市白云区机场高速", "广东省东莞市南城街道", "广东省佛山市顺德区伦教街道", "上海市临港新片区", "四川省成都市高新区"}
|
||||
statuses := []string{"unprocessed", "unprocessed", "processing", "recovered", "closed", "ignored"}
|
||||
severities := []string{"critical", "critical", "major", "major", "minor", "minor"}
|
||||
severities := []string{"critical", "major", "major", "major", "minor", "critical"}
|
||||
plates := []string{"粤AG18312", "粤B7C526", "粤C9D872", "粤E6P987", "豫A88888", "川AHTWO1"}
|
||||
vins := []string{"LB9A32A24R0LS1426", "LFP23A98V2P012345", "LS5A3A5E8N0123456", "LJ12BA3R1N0456789", "LMRKH9AC2R1004087", "LNXNEGRR7SR318212"}
|
||||
eventSpecs := []struct {
|
||||
ruleID, name, triggerType, protocol, metric, operator, unit string
|
||||
value, threshold float64
|
||||
duration int
|
||||
}{
|
||||
{"rule-geofence", "车辆驶出运营围栏", "geofence", "JT808", "geofence_distance_m", "exit", "m", 5230, 5000, 0},
|
||||
{"rule-low-soc", "SOC 低于 20%", "metric", "GB32960", "soc_percent", "lt", "%", 18, 20, 300},
|
||||
{"rule-speeding", "车辆急加速", "metric", "JT808", "speed_kmh", "gt", "km/h", 92, 80, 10},
|
||||
{"rule-offline", "车辆恢复在线", "offline", "YUTONG_MQTT", "freshness_sec", "gt", "s", 22, 3600, 0},
|
||||
{"rule-mileage", "里程数据完成日结", "metric", "GB32960", "daily_mileage_km", "gte", "km", 186.4, 0, 0},
|
||||
{"rule-alarm", "最高氢浓度超限", "metric", "GB32960", "alarm_active", "eq", "", 1, 1, 0},
|
||||
}
|
||||
for i := range statuses {
|
||||
triggered := now.Add(-time.Duration(8+i*11) * time.Minute)
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: "rule-speeding", RuleName: "持续超速告警", RuleVersion: 2, Severity: severities[i], Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: []string{"JT808", "JT808", "JT808", "GB32960", "YUTONG_MQTT", "GB32960"}[i], Metric: "speed_kmh", Operator: "gt", TriggerValue: 96 - float64(i*3), Threshold: 80, Unit: "km/h", DurationSec: 60, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
spec := eventSpecs[i]
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: spec.ruleID, RuleName: spec.name, RuleVersion: 2, Severity: severities[i], TriggerType: spec.triggerType, Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: spec.protocol, Metric: spec.metric, Operator: spec.operator, TriggerValue: spec.value, Threshold: spec.threshold, Unit: spec.unit, DurationSec: spec.duration, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
if statuses[i] == "processing" {
|
||||
event.Handler = "张三"
|
||||
}
|
||||
if statuses[i] == "recovered" || statuses[i] == "closed" || statuses[i] == "ignored" {
|
||||
event.RecoveredAt = triggered.Add(5 * time.Minute).Format(time.RFC3339)
|
||||
}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "alert-evaluator", Note: "规则命中并达到持续时间", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "event-evaluator", Note: "标准事件已匹配自动化", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
if statuses[i] != "unprocessed" {
|
||||
event.Actions = append(event.Actions, AlertAction{ID: int64(20 + i), Action: statuses[i], FromStatus: "unprocessed", ToStatus: statuses[i], Actor: firstNonEmpty(event.Handler, "platform-admin"), CreatedAt: triggered.Add(time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
@@ -37,9 +68,17 @@ func (m *MockStore) seedAlertCenter() {
|
||||
}
|
||||
m.nextAlertActionID = 100
|
||||
for i := 0; i < 9; i++ {
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 触发告警,请及时处理", Severity: severities[i%len(severities)], Channel: "in_app", Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 的车辆事件已执行通知动作", Severity: severities[i%len(severities)], Channel: "in_app", DeliveryStatus: "delivered", AttemptCount: 1, MaxAttempts: alertNotificationMaxAttempts, Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
m.nextNotificationID = 10
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{
|
||||
ID: 10, EventID: m.alertEvents[1].ID, Title: "SOC 低电量短信通知", Content: "粤B7C526 的 SOC 已低于 20%,短信网关连接超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", RecipientID: "night-shift", DeliveryStatus: "failed", AttemptCount: 2,
|
||||
VehiclePlate: "粤B7C526", VehicleVIN: m.alertEvents[1].VIN, Protocol: m.alertEvents[1].Protocol,
|
||||
CreatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339), LastAttemptAt: now.Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
LastError: "短信网关连接超时(provider_timeout)", RetryAvailable: true, MaxAttempts: alertNotificationMaxAttempts,
|
||||
})
|
||||
m.nextNotificationID = 11
|
||||
m.nextNotificationRetryAuditID = 0
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
@@ -66,7 +105,7 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
}
|
||||
for _, item := range m.alertNotifications {
|
||||
if !item.Read {
|
||||
if item.Channel == "in_app" && !item.Read {
|
||||
result.UnreadNotifications++
|
||||
}
|
||||
}
|
||||
@@ -161,7 +200,99 @@ func (m *MockStore) AlertEvent(_ context.Context, id string) (AlertEvent, error)
|
||||
func (m *MockStore) AlertRules(context.Context) ([]AlertRule, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
return append([]AlertRule(nil), m.alertRules...), nil
|
||||
items := make([]AlertRule, 0, len(m.alertRules))
|
||||
for _, rule := range m.alertRules {
|
||||
if rule.ArchivedAt == "" {
|
||||
items = append(items, rule)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRulePage(_ context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
summary := AlertRuleLibrarySummary{}
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
search := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, rule := range m.alertRules {
|
||||
archived := rule.ArchivedAt != ""
|
||||
if archived {
|
||||
summary.Archived++
|
||||
} else {
|
||||
summary.Current++
|
||||
if rule.Enabled {
|
||||
summary.Enabled++
|
||||
} else {
|
||||
summary.Disabled++
|
||||
}
|
||||
}
|
||||
if (query.Lifecycle == "archived") != archived {
|
||||
continue
|
||||
}
|
||||
if query.Status == "enabled" && !rule.Enabled || query.Status == "disabled" && rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if query.Protocol != "" && len(rule.ScopeProtocols) > 0 {
|
||||
matched := false
|
||||
for _, protocol := range rule.ScopeProtocols {
|
||||
if protocol == query.Protocol {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
rule.Name, rule.Description, rule.Metric, strings.Join(rule.ScopeVINs, " "),
|
||||
strings.Join(rule.ScopeOEMs, " "), strings.Join(rule.ScopeModels, " "),
|
||||
strings.Join(rule.ScopeCompanies, " "), rule.ArchiveReason,
|
||||
}, " "))
|
||||
if !strings.Contains(haystack, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, rule)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if query.Lifecycle == "archived" {
|
||||
if items[i].ArchivedAt != items[j].ArchivedAt {
|
||||
return items[i].ArchivedAt > items[j].ArchivedAt
|
||||
}
|
||||
} else if items[i].Enabled != items[j].Enabled {
|
||||
return items[i].Enabled
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
total := len(items)
|
||||
start := min(query.Offset, total)
|
||||
end := min(start+query.Limit, total)
|
||||
return AlertRulePage{Items: append([]AlertRule(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRuleRevisions(_ context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items, ok := m.alertRuleRevisions[id]
|
||||
if !ok {
|
||||
return nil, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
return append([]AlertRuleRevision(nil), items...), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) recordAlertRuleRevision(rule AlertRule, actor, action string, reasons ...string) {
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
reason := ""
|
||||
if len(reasons) > 0 {
|
||||
reason = reasons[0]
|
||||
}
|
||||
revision := AlertRuleRevision{RuleID: rule.ID, Version: rule.Version, Actor: actor, Action: action, Reason: reason, CreatedAt: rule.UpdatedAt, Snapshot: rule}
|
||||
m.alertRuleRevisions[rule.ID] = append([]AlertRuleRevision{revision}, m.alertRuleRevisions[rule.ID]...)
|
||||
}
|
||||
|
||||
func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
@@ -172,6 +303,9 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
if current.ID != input.ID {
|
||||
continue
|
||||
}
|
||||
if current.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if input.Version != current.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -182,6 +316,7 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.UpdatedAt = now
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules[i] = next
|
||||
m.recordAlertRuleRevision(next, input.Actor, firstNonEmpty(input.AuditAction, "update"))
|
||||
return next, nil
|
||||
}
|
||||
if input.Version != 0 {
|
||||
@@ -194,11 +329,15 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.CreatedBy = input.Actor
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules = append(m.alertRules, next)
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
m.recordAlertRuleRevision(next, input.Actor, "create")
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ruleFromInput(input AlertRuleInput) AlertRule {
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), Enabled: input.Enabled}
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, TriggerType: input.TriggerType, FenceName: input.FenceName, FenceLongitude: input.FenceLongitude, FenceLatitude: input.FenceLatitude, FenceRadiusM: input.FenceRadiusM, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), input.NotificationTargets...), Enabled: input.Enabled}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
@@ -206,6 +345,9 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
if m.alertRules[i].ID == id {
|
||||
if m.alertRules[i].ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if m.alertRules[i].Version != update.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -213,12 +355,56 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
m.alertRules[i].Version++
|
||||
m.alertRules[i].UpdatedBy = update.Actor
|
||||
m.alertRules[i].UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
m.recordAlertRuleRevision(m.alertRules[i], update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled])
|
||||
return m.alertRules[i], nil
|
||||
}
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "规则不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleArchived(_ context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
rule := &m.alertRules[i]
|
||||
if rule.ID != id {
|
||||
continue
|
||||
}
|
||||
if rule.Version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if rule.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if rule.Enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
rule.ArchivedAt = time.Now().Format(time.RFC3339)
|
||||
rule.ArchivedBy = request.Actor
|
||||
rule.ArchiveReason = request.Reason
|
||||
} else {
|
||||
if rule.ArchivedAt == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
rule.ArchivedAt = ""
|
||||
rule.ArchivedBy = ""
|
||||
rule.ArchiveReason = ""
|
||||
rule.Enabled = false
|
||||
}
|
||||
rule.Version++
|
||||
rule.UpdatedBy = request.Actor
|
||||
rule.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
m.recordAlertRuleRevision(*rule, request.Actor, action, request.Reason)
|
||||
return *rule, nil
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) ActOnAlert(_ context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -265,10 +451,46 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotification, 0, len(m.alertNotifications))
|
||||
search := strings.ToLower(query.Search)
|
||||
for _, item := range m.alertNotifications {
|
||||
if !query.UnreadOnly || !item.Read {
|
||||
items = append(items, item)
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(item.VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if query.UnreadOnly && (item.Channel != "in_app" || item.Read) {
|
||||
continue
|
||||
}
|
||||
deliveryStatus := strings.ToLower(strings.TrimSpace(item.DeliveryStatus))
|
||||
if deliveryStatus == "sent" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if deliveryStatus == "created" || deliveryStatus == "reserved" {
|
||||
deliveryStatus = "queued"
|
||||
}
|
||||
if deliveryStatus == "" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if query.DeliveryStatus != "" && deliveryStatus != query.DeliveryStatus {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(strings.Join([]string{item.Title, item.Content, item.EventID, item.VehiclePlate, item.VehicleVIN, item.Recipient, item.Protocol}, "\n")), search) {
|
||||
continue
|
||||
}
|
||||
item.DeliveryStatus = deliveryStatus
|
||||
if item.AttemptCount <= 0 {
|
||||
item.AttemptCount = 1
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = deliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
items = append(items, item)
|
||||
}
|
||||
total := len(items)
|
||||
start := query.Offset
|
||||
@@ -282,6 +504,48 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
return Page[AlertNotification]{Items: append([]AlertNotification(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationDeliveryHealth(_ context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
byChannel := map[string]*AlertNotificationChannelHealth{}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
channel := byChannel[notification.Channel]
|
||||
if channel == nil {
|
||||
channel = &AlertNotificationChannelHealth{Channel: notification.Channel}
|
||||
byChannel[notification.Channel] = channel
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(notification.DeliveryStatus))
|
||||
if status == "created" || status == "reserved" || status == "queued" {
|
||||
channel.Queued++
|
||||
health.Queued++
|
||||
if channel.OldestQueuedAt == "" || notification.CreatedAt < channel.OldestQueuedAt {
|
||||
channel.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
if health.OldestQueuedAt == "" || notification.CreatedAt < health.OldestQueuedAt {
|
||||
health.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
}
|
||||
if status == "failed" {
|
||||
channel.Failed++
|
||||
health.Failed++
|
||||
if notification.AttemptCount >= alertNotificationMaxAttempts {
|
||||
channel.DeadLetter++
|
||||
health.DeadLetter++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sms", "email", "wecom"} {
|
||||
if channel := byChannel[key]; channel != nil {
|
||||
health.Channels = append(health.Channels, *channel)
|
||||
}
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -292,7 +556,19 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
count := 0
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for i := range m.alertNotifications {
|
||||
if ids[m.alertNotifications[i].ID] && !m.alertNotifications[i].Read {
|
||||
if ids[m.alertNotifications[i].ID] && m.alertNotifications[i].Channel == "in_app" && !m.alertNotifications[i].Read {
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range request.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(m.alertNotifications[i].VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
m.alertNotifications[i].Read = true
|
||||
m.alertNotifications[i].ReadAt = now
|
||||
count++
|
||||
@@ -301,6 +577,85 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) RetryAlertNotification(_ context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if audit.IdempotencyKey == request.IdempotencyKey {
|
||||
if audit.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.ID == id {
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: audit, Idempotent: true}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range m.alertNotifications {
|
||||
notification := &m.alertNotifications[index]
|
||||
if notification.ID != id {
|
||||
continue
|
||||
}
|
||||
attemptCount := notification.AttemptCount
|
||||
if attemptCount <= 0 {
|
||||
attemptCount = 1
|
||||
}
|
||||
if notification.DeliveryStatus != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
nextStatus := "queued"
|
||||
auditNextStatus := "reserved"
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if notification.Channel == "in_app" {
|
||||
nextStatus = "delivered"
|
||||
auditNextStatus = "sent"
|
||||
notification.DeliveredAt = now
|
||||
notification.ProviderMessageID = "in_app:" + strconv.FormatInt(notification.ID, 10) + ":retry:" + strconv.Itoa(attemptCount+1)
|
||||
} else {
|
||||
notification.ProviderMessageID = ""
|
||||
notification.DeliveredAt = ""
|
||||
}
|
||||
notification.DeliveryStatus = nextStatus
|
||||
notification.AttemptCount = attemptCount + 1
|
||||
notification.LastAttemptAt = now
|
||||
notification.RetryRequestedBy = request.Actor
|
||||
notification.RetryRequestedAt = now
|
||||
notification.RetryAvailable = false
|
||||
notification.MaxAttempts = alertNotificationMaxAttempts
|
||||
m.nextNotificationRetryAuditID++
|
||||
audit := AlertNotificationRetryAudit{
|
||||
ID: m.nextNotificationRetryAuditID, NotificationID: id, IdempotencyKey: request.IdempotencyKey, Actor: request.Actor,
|
||||
Reason: request.Reason, PreviousStatus: "failed", NextStatus: auditNextStatus,
|
||||
AttemptCount: notification.AttemptCount, RequestedAt: now,
|
||||
}
|
||||
m.alertNotificationRetryAudits = append([]AlertNotificationRetryAudit{audit}, m.alertNotificationRetryAudits...)
|
||||
return AlertNotificationRetryResult{Notification: *notification, Receipt: audit}, nil
|
||||
}
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationRetryAudits(_ context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.NotificationID == id {
|
||||
items = append(items, audit)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateAlerts(context.Context) (AlertEvaluationResult, error) {
|
||||
return AlertEvaluationResult{RulesEvaluated: len(m.alertRules), VehiclesScanned: len(m.vehicles), AsOf: time.Now().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertNotificationDispatchItem struct {
|
||||
ID int64 `json:"notificationId"`
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Severity string `json:"severity"`
|
||||
Channel string `json:"channel"`
|
||||
RecipientID string `json:"recipientId"`
|
||||
Recipient string `json:"recipient"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
VehiclePlate string `json:"vehiclePlate"`
|
||||
VehicleVIN string `json:"vehicleVin"`
|
||||
Protocol string `json:"protocol"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LeaseToken string `json:"-"`
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchReceipt struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchBatchResult struct {
|
||||
Claimed int
|
||||
Sent int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type AlertNotificationGateway interface {
|
||||
Send(context.Context, AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error)
|
||||
}
|
||||
|
||||
type alertNotificationDispatchStore interface {
|
||||
ClaimAlertNotifications(context.Context, string, []string, int, time.Duration) ([]AlertNotificationDispatchItem, error)
|
||||
CompleteAlertNotificationDispatch(context.Context, AlertNotificationDispatchItem, AlertNotificationDispatchReceipt, error) error
|
||||
}
|
||||
|
||||
type AlertNotificationDispatcher struct {
|
||||
store alertNotificationDispatchStore
|
||||
gateways map[string]AlertNotificationGateway
|
||||
channels []string
|
||||
batchSize int
|
||||
lease time.Duration
|
||||
workerID string
|
||||
}
|
||||
|
||||
func NewAlertNotificationDispatcher(store alertNotificationDispatchStore, gateways map[string]AlertNotificationGateway, batchSize int, lease time.Duration, workerID string) *AlertNotificationDispatcher {
|
||||
normalized := map[string]AlertNotificationGateway{}
|
||||
channels := make([]string, 0, len(gateways))
|
||||
for _, channel := range []string{"sms", "email", "wecom"} {
|
||||
if gateway := gateways[channel]; gateway != nil {
|
||||
normalized[channel] = gateway
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 20
|
||||
}
|
||||
if batchSize > 200 {
|
||||
batchSize = 200
|
||||
}
|
||||
if lease < 5*time.Second {
|
||||
lease = 30 * time.Second
|
||||
}
|
||||
if lease > 5*time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
workerID = "notification-dispatcher"
|
||||
}
|
||||
return &AlertNotificationDispatcher{store: store, gateways: normalized, channels: channels, batchSize: batchSize, lease: lease, workerID: workerID}
|
||||
}
|
||||
|
||||
func (d *AlertNotificationDispatcher) RunOnce(ctx context.Context) (AlertNotificationDispatchBatchResult, error) {
|
||||
if len(d.channels) == 0 {
|
||||
return AlertNotificationDispatchBatchResult{}, nil
|
||||
}
|
||||
token, err := alertNotificationLeaseToken(d.workerID)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
items, err := d.store.ClaimAlertNotifications(ctx, token, d.channels, d.batchSize, d.lease)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
result := AlertNotificationDispatchBatchResult{Claimed: len(items)}
|
||||
var completionErrors []string
|
||||
for _, item := range items {
|
||||
receipt, sendErr := d.gateways[item.Channel].Send(ctx, item)
|
||||
if sendErr == nil {
|
||||
result.Sent++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
if completeErr := d.store.CompleteAlertNotificationDispatch(ctx, item, receipt, sendErr); completeErr != nil {
|
||||
completionErrors = append(completionErrors, completeErr.Error())
|
||||
}
|
||||
}
|
||||
if len(completionErrors) > 0 {
|
||||
return result, fmt.Errorf("complete notification dispatch: %s", strings.Join(completionErrors, "; "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func alertNotificationLeaseToken(workerID string) (string, error) {
|
||||
buf := make([]byte, 12)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.Map(func(char rune) rune {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' || char == ':' {
|
||||
return char
|
||||
}
|
||||
return '-'
|
||||
}, workerID) + ":" + hex.EncodeToString(buf)
|
||||
if len(token) > 96 {
|
||||
token = token[len(token)-96:]
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAlertNotifications(ctx context.Context, leaseToken string, channels []string, limit int, lease time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !alertNotificationTargetIDValid(leaseToken) {
|
||||
return nil, fmt.Errorf("invalid notification lease token")
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
return nil, fmt.Errorf("notification dispatch batch size must be between 1 and 200")
|
||||
}
|
||||
allowed := map[string]bool{"sms": true, "email": true, "wecom": true}
|
||||
cleanChannels := make([]string, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||
if allowed[channel] && !containsString(cleanChannels, channel) {
|
||||
cleanChannels = append(cleanChannels, channel)
|
||||
}
|
||||
}
|
||||
if len(cleanChannels) == 0 {
|
||||
return []AlertNotificationDispatchItem{}, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
placeholders := make([]string, len(cleanChannels))
|
||||
args := make([]any, 0, len(cleanChannels)+1)
|
||||
for index, channel := range cleanChannels {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, channel)
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,n.recipient,n.recipient_ref,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,''),DATE_FORMAT(n.created_at,'`+alertSQLTimestampFormat+`')
|
||||
FROM vehicle_alert_notification n
|
||||
LEFT JOIN vehicle_alert_event e ON e.id=n.event_id
|
||||
WHERE n.delivery_status='reserved'
|
||||
AND n.channel IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND n.recipient_ref<>''
|
||||
AND (n.lease_expires_at IS NULL OR n.lease_expires_at<=NOW(3))
|
||||
ORDER BY n.created_at,n.id
|
||||
LIMIT ? FOR UPDATE SKIP LOCKED`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AlertNotificationDispatchItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var item AlertNotificationDispatchItem
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient, &item.RecipientID, &item.AttemptCount, &item.VehiclePlate, &item.VehicleVIN, &item.Protocol, &item.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.LeaseToken = leaseToken
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaseSeconds := int(lease.Round(time.Second) / time.Second)
|
||||
if leaseSeconds < 5 {
|
||||
leaseSeconds = 30
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET lease_token=?,lease_expires_at=DATE_ADD(NOW(3),INTERVAL ? SECOND),last_attempt_at=NOW(3) WHERE id=? AND delivery_status='reserved'`, leaseToken, leaseSeconds, item.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CompleteAlertNotificationDispatch(ctx context.Context, item AlertNotificationDispatchItem, receipt AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
if item.ID <= 0 || item.LeaseToken == "" {
|
||||
return fmt.Errorf("notification dispatch receipt is missing its lease")
|
||||
}
|
||||
var result sql.Result
|
||||
var err error
|
||||
if sendErr == nil {
|
||||
messageID := strings.TrimSpace(receipt.MessageID)
|
||||
if len(messageID) > 160 {
|
||||
messageID = messageID[:160]
|
||||
}
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',provider_message_id=?,delivered_at=NOW(3),last_error='',lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, messageID, item.ID, item.LeaseToken)
|
||||
} else {
|
||||
lastError := sanitizeAlertNotificationDispatchError(sendErr)
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='failed',last_error=?,delivered_at=NULL,lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, lastError, item.ID, item.LeaseToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated != 1 {
|
||||
return fmt.Errorf("notification %d lease expired or was replaced", item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeAlertNotificationDispatchError(err error) string {
|
||||
value := strings.Join(strings.Fields(err.Error()), " ")
|
||||
if value == "" {
|
||||
value = "notification gateway failed without details"
|
||||
}
|
||||
if len([]rune(value)) > 500 {
|
||||
value = string([]rune(value)[:500])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type HTTPAlertNotificationGateway struct {
|
||||
endpoint string
|
||||
secret []byte
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPAlertNotificationGateway(endpoint, secret string, timeout time.Duration) (*HTTPAlertNotificationGateway, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
secret = strings.TrimSpace(secret)
|
||||
if endpoint == "" || secret == "" {
|
||||
return nil, fmt.Errorf("notification gateway endpoint and signing secret are required")
|
||||
}
|
||||
if timeout < time.Second {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if timeout > 30*time.Second {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &HTTPAlertNotificationGateway{endpoint: endpoint, secret: []byte(secret), client: &http.Client{Timeout: timeout}, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (g *HTTPAlertNotificationGateway) Send(ctx context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
body, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(g.now().UnixMilli(), 10)
|
||||
mac := hmac.New(sha256.New, g.secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("\n"))
|
||||
_, _ = mac.Write(body)
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Lingniu-Timestamp", timestamp)
|
||||
request.Header.Set("X-Lingniu-Signature", "sha256="+signature)
|
||||
request.Header.Set("X-Lingniu-Notification-ID", strconv.FormatInt(item.ID, 10))
|
||||
request.Header.Set("X-Lingniu-Idempotency-Key", fmt.Sprintf("notification:%d:attempt:%d", item.ID, item.AttemptCount))
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
if readErr != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway response read failed: %w", readErr)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
detail := strings.TrimSpace(string(responseBody))
|
||||
if len(detail) > 200 {
|
||||
detail = detail[:200]
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway returned HTTP %d%s", response.StatusCode, map[bool]string{true: ": " + detail, false: ""}[detail != ""])
|
||||
}
|
||||
var payload struct {
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &payload)
|
||||
if payload.MessageID == "" {
|
||||
payload.MessageID = response.Header.Get("X-Provider-Message-ID")
|
||||
}
|
||||
payload.MessageID = strings.TrimSpace(payload.MessageID)
|
||||
if payload.MessageID == "" {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway accepted the request without a message id")
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{MessageID: payload.MessageID}, nil
|
||||
}
|
||||
|
||||
var _ alertNotificationDispatchStore = (*ProductionStore)(nil)
|
||||
@@ -0,0 +1,133 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertNotificationDispatchStoreStub struct {
|
||||
items []AlertNotificationDispatchItem
|
||||
claimedWith []string
|
||||
completed []AlertNotificationDispatchItem
|
||||
sendErrors []error
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) ClaimAlertNotifications(_ context.Context, token string, channels []string, _ int, _ time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
s.claimedWith = append([]string(nil), channels...)
|
||||
items := make([]AlertNotificationDispatchItem, len(s.items))
|
||||
copy(items, s.items)
|
||||
for index := range items {
|
||||
items[index].LeaseToken = token
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) CompleteAlertNotificationDispatch(_ context.Context, item AlertNotificationDispatchItem, _ AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
s.completed = append(s.completed, item)
|
||||
s.sendErrors = append(s.sendErrors, sendErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
type alertNotificationGatewayStub struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (g alertNotificationGatewayStub) Send(_ context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
return AlertNotificationDispatchReceipt{MessageID: "provider-" + item.EventID}, g.err
|
||||
}
|
||||
|
||||
func TestAlertNotificationDispatcherUsesLeasesAndPersistsRealOutcomes(t *testing.T) {
|
||||
store := &alertNotificationDispatchStoreStub{items: []AlertNotificationDispatchItem{
|
||||
{ID: 11, EventID: "event-11", Channel: "sms", RecipientID: "night-shift", AttemptCount: 1},
|
||||
{ID: 12, EventID: "event-12", Channel: "email", RecipientID: "data-platform", AttemptCount: 2},
|
||||
}}
|
||||
dispatcher := NewAlertNotificationDispatcher(store, map[string]AlertNotificationGateway{
|
||||
"sms": alertNotificationGatewayStub{},
|
||||
"email": alertNotificationGatewayStub{err: errors.New("provider timeout")},
|
||||
}, 20, 30*time.Second, "worker-a")
|
||||
|
||||
result, err := dispatcher.RunOnce(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Claimed != 2 || result.Sent != 1 || result.Failed != 1 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if len(store.completed) != 2 || store.completed[0].LeaseToken == "" || store.completed[1].LeaseToken == "" {
|
||||
t.Fatalf("claimed items must retain their leases: %+v", store.completed)
|
||||
}
|
||||
if store.sendErrors[0] != nil || store.sendErrors[1] == nil {
|
||||
t.Fatalf("real gateway outcomes were not preserved: %+v", store.sendErrors)
|
||||
}
|
||||
if len(store.claimedWith) != 2 || store.claimedWith[0] != "sms" || store.claimedWith[1] != "email" {
|
||||
t.Fatalf("unexpected claimed channels: %+v", store.claimedWith)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewaySignsIdempotentPayload(t *testing.T) {
|
||||
const secret = "notification-secret"
|
||||
now := time.Date(2026, 7, 23, 8, 30, 0, 0, time.UTC)
|
||||
var received AlertNotificationDispatchItem
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(request.Header.Get("X-Lingniu-Timestamp") + "\n"))
|
||||
_, _ = mac.Write(body)
|
||||
if request.Header.Get("X-Lingniu-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
|
||||
t.Errorf("invalid signature: %s", request.Header.Get("X-Lingniu-Signature"))
|
||||
}
|
||||
if request.Header.Get("X-Lingniu-Idempotency-Key") != "notification:42:attempt:2" {
|
||||
t.Errorf("invalid idempotency key: %s", request.Header.Get("X-Lingniu-Idempotency-Key"))
|
||||
}
|
||||
if err := json.Unmarshal(body, &received); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"messageId":"sms-provider-42"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, secret, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateway.now = func() time.Time { return now }
|
||||
receipt, err := gateway.Send(t.Context(), AlertNotificationDispatchItem{
|
||||
ID: 42, EventID: "event-42", Title: "低电量", Content: "SOC 低于 20%", Channel: "sms",
|
||||
RecipientID: "night-shift", Recipient: "夜班负责人", AttemptCount: 2, LeaseToken: "must-not-leak",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.MessageID != "sms-provider-42" || received.RecipientID != "night-shift" || received.LeaseToken != "" {
|
||||
t.Fatalf("unexpected gateway receipt or payload: receipt=%+v payload=%+v", receipt, received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewayRejectsAmbiguousSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, "secret", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = gateway.Send(t.Context(), AlertNotificationDispatchItem{ID: 7, Channel: "sms", RecipientID: "night-shift", AttemptCount: 1})
|
||||
if err == nil {
|
||||
t.Fatal("2xx without provider message id must not be marked sent")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -106,16 +107,20 @@ func buildAlertWhere(query AlertQuery) (string, []any) {
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.status,e.vin,e.plate,e.protocol,
|
||||
const alertSQLTimestampFormat = `%Y-%m-%dT%H:%i:%s.%f+08:00`
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.trigger_type,e.status,e.vin,e.plate,e.protocol,
|
||||
e.metric,e.operator,e.trigger_value,e.threshold_value,e.threshold_high,e.unit,e.duration_sec,e.location_text,e.longitude,e.latitude,
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),COALESCE(DATE_FORMAT(e.received_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),
|
||||
DATE_FORMAT(e.triggered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(e.recovered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),e.handler,e.version
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(e.received_at,'` + alertSQLTimestampFormat + `'),''),
|
||||
DATE_FORMAT(e.triggered_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(e.recovered_at,'` + alertSQLTimestampFormat + `'),''),e.handler,e.version
|
||||
FROM vehicle_alert_event e `
|
||||
|
||||
const alertActionSelect = `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`
|
||||
|
||||
func scanAlertEvent(scanner interface{ Scan(...any) error }) (AlertEvent, error) {
|
||||
var event AlertEvent
|
||||
var longitude, latitude sql.NullFloat64
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.TriggerType, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
&event.Metric, &event.Operator, &event.TriggerValue, &event.Threshold, &event.ThresholdHigh, &event.Unit, &event.DurationSec, &event.Location, &longitude, &latitude,
|
||||
&event.SourceEventID, &event.EventAt, &event.ReceivedAt, &event.TriggeredAt, &event.RecoveredAt, &event.Handler, &event.Version)
|
||||
if longitude.Valid {
|
||||
@@ -164,7 +169,7 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`, id)
|
||||
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
@@ -179,15 +184,73 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
return event, rows.Err()
|
||||
}
|
||||
|
||||
const alertRuleSelect = `SELECT id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_rule `
|
||||
const alertRuleSelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_rule `
|
||||
|
||||
const alertRuleLibrarySelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `'),COALESCE(archived_by,''),COALESCE(DATE_FORMAT(archived_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(archive_reason,'') FROM vehicle_alert_rule `
|
||||
|
||||
const alertNotificationSelect = `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,COALESCE(n.recipient,''),COALESCE(n.recipient_ref,''),n.delivery_status,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(n.provider_message_id,''),n.is_read,DATE_FORMAT(n.created_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(n.delivered_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(n.read_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.last_error,''),COALESCE(DATE_FORMAT(n.last_attempt_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.retry_requested_by,''),COALESCE(DATE_FORMAT(n.retry_requested_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,'') FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `
|
||||
|
||||
const alertNotificationRetryAuditSelect = `SELECT id,notification_id,actor,reason,previous_status,next_status,attempt_count,DATE_FORMAT(requested_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_notification_retry_audit WHERE `
|
||||
|
||||
const alertNotificationMaxAttempts = 3
|
||||
|
||||
type alertRowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanAlertNotification(scanner alertRowScanner) (AlertNotification, error) {
|
||||
var item AlertNotification
|
||||
var deliveryStatus string
|
||||
err := scanner.Scan(
|
||||
&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient,
|
||||
&item.RecipientID, &deliveryStatus, &item.AttemptCount, &item.ProviderMessageID, &item.Read, &item.CreatedAt, &item.DeliveredAt, &item.ReadAt,
|
||||
&item.LastError, &item.LastAttemptAt, &item.RetryRequestedBy, &item.RetryRequestedAt,
|
||||
&item.VehiclePlate, &item.VehicleVIN, &item.Protocol,
|
||||
)
|
||||
if err != nil {
|
||||
return AlertNotification{}, err
|
||||
}
|
||||
switch deliveryStatus {
|
||||
case "failed":
|
||||
item.DeliveryStatus = "failed"
|
||||
case "created", "reserved":
|
||||
item.DeliveryStatus = "queued"
|
||||
default:
|
||||
item.DeliveryStatus = "delivered"
|
||||
}
|
||||
if item.Recipient == "" {
|
||||
if item.Channel == "in_app" {
|
||||
item.Recipient = "运营值班组"
|
||||
} else {
|
||||
item.Recipient = "规则通知目标"
|
||||
}
|
||||
}
|
||||
if item.ProviderMessageID == "" && item.Channel == "in_app" {
|
||||
item.ProviderMessageID = fmt.Sprintf("in_app:%d", item.ID)
|
||||
}
|
||||
if item.DeliveryStatus == "delivered" && item.DeliveredAt == "" {
|
||||
item.DeliveredAt = item.CreatedAt
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = item.DeliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanAlertNotificationRetryAudit(scanner alertRowScanner) (AlertNotificationRetryAudit, error) {
|
||||
var item AlertNotificationRetryAudit
|
||||
err := scanner.Scan(&item.ID, &item.NotificationID, &item.Actor, &item.Reason, &item.PreviousStatus, &item.NextStatus, &item.AttemptCount, &item.RequestedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
@@ -210,15 +273,58 @@ func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
if e := json.Unmarshal([]byte(channels), &rule.NotificationChannels); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
if e := json.Unmarshal([]byte(targets), &rule.NotificationTargets); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if e := normalizeAlertNotificationTargets(&input); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
return rule, err
|
||||
}
|
||||
|
||||
func scanAlertRuleLibrary(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt, &rule.ArchivedBy, &rule.ArchivedAt, &rule.ArchiveReason)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
for _, item := range []struct {
|
||||
raw string
|
||||
target *[]string
|
||||
}{
|
||||
{protocols, &rule.ScopeProtocols}, {vins, &rule.ScopeVINs}, {oems, &rule.ScopeOEMs},
|
||||
{models, &rule.ScopeModels}, {companies, &rule.ScopeCompanies}, {channels, &rule.NotificationChannels},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(item.raw), item.target); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(targets), &rule.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`ORDER BY enabled DESC,severity,name`)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`WHERE archived_at IS NULL ORDER BY enabled DESC,severity,name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -234,39 +340,138 @@ func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, error) {
|
||||
func buildAlertRuleWhere(query AlertRuleQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.Lifecycle == "archived" {
|
||||
where = append(where, "archived_at IS NOT NULL")
|
||||
} else {
|
||||
where = append(where, "archived_at IS NULL")
|
||||
}
|
||||
if query.Status == "enabled" {
|
||||
where = append(where, "enabled=1")
|
||||
} else if query.Status == "disabled" {
|
||||
where = append(where, "enabled=0")
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
where = append(where, "(JSON_LENGTH(scope_protocols_json)=0 OR JSON_CONTAINS(scope_protocols_json,?))")
|
||||
encoded, _ := json.Marshal(query.Protocol)
|
||||
args = append(args, string(encoded))
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(name LIKE ? OR description LIKE ? OR metric LIKE ? OR scope_vins_json LIKE ? OR scope_oems_json LIKE ? OR scope_models_json LIKE ? OR scope_companies_json LIKE ? OR archive_reason LIKE ?)")
|
||||
for range 8 {
|
||||
args = append(args, like)
|
||||
}
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
var summary AlertRuleLibrarySummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=1 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=0 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END),0)
|
||||
FROM vehicle_alert_rule`).Scan(&summary.Current, &summary.Enabled, &summary.Disabled, &summary.Archived); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
where, args := buildAlertRuleWhere(query)
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
order := `enabled DESC,severity,updated_at DESC,id`
|
||||
if query.Lifecycle == "archived" {
|
||||
order = `archived_at DESC,id`
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleLibrarySelect+`WHERE `+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
item, err := scanAlertRuleLibrary(rows)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AlertRulePage{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT rule_id,rule_version,actor,action,COALESCE(reason,''),snapshot_json,DATE_FORMAT(created_at,'`+alertSQLTimestampFormat+`') FROM vehicle_alert_rule_audit WHERE rule_id=? ORDER BY rule_version DESC,id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertRuleRevision{}
|
||||
for rows.Next() {
|
||||
var item AlertRuleRevision
|
||||
var snapshot string
|
||||
if err := rows.Scan(&item.RuleID, &item.Version, &item.Actor, &item.Action, &item.Reason, &snapshot, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(snapshot), &item.Snapshot); err != nil {
|
||||
return nil, fmt.Errorf("parse alert rule revision %d: %w", item.Version, err)
|
||||
}
|
||||
item.Snapshot.ID = item.RuleID
|
||||
item.Snapshot.Version = item.Version
|
||||
item.Snapshot.UpdatedBy = item.Actor
|
||||
item.Snapshot.UpdatedAt = item.CreatedAt
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, string, error) {
|
||||
p, e := json.Marshal(input.ScopeProtocols)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
v, e := json.Marshal(input.ScopeVINs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
o, e := json.Marshal(input.ScopeOEMs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
m, e := json.Marshal(input.ScopeModels)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
co, e := json.Marshal(input.ScopeCompanies)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
c, e := json.Marshal(input.NotificationChannels)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), e
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
t, e := json.Marshal(input.NotificationTargets)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), string(t), e
|
||||
}
|
||||
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,notification_targets_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,trigger_type=?,fence_name=?,fence_longitude=?,fence_latitude=?,fence_radius_m=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,notification_targets_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`
|
||||
|
||||
func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
p, v, o, m, co, c, err := marshalAlertRuleLists(input)
|
||||
p, v, o, m, co, c, t, err := marshalAlertRuleLists(input)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -276,20 +481,24 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current int
|
||||
err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t)
|
||||
var archivedAt sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t, &archivedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
if input.Version != 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.Actor)
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.Actor)
|
||||
current = 0
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
} else {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if current != input.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.ID, current)
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.ID, current)
|
||||
err = e
|
||||
if err == nil {
|
||||
n, _ := res.RowsAffected()
|
||||
@@ -301,8 +510,11 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(input)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, input.ID, current+1, input.Actor, firstNonEmpty(map[bool]string{true: "create", false: "update"}[current == 0], "update"), string(snapshot)); err != nil {
|
||||
auditSnapshot := input
|
||||
auditSnapshot.Version = current + 1
|
||||
snapshot, _ := json.Marshal(auditSnapshot)
|
||||
action := firstNonEmpty(strings.TrimSpace(input.AuditAction), map[bool]string{true: "create", false: "update"}[current == 0], "update")
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, input.ID, current+1, input.Actor, action, "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -320,7 +532,7 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`, update.Enabled, update.Actor, id, update.Version)
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, update.Enabled, update.Actor, id, update.Version)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -341,7 +553,70 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
}
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], string(snapshot)); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var enabled bool
|
||||
var version int
|
||||
var archivedAt sql.NullTime
|
||||
if err = tx.QueryRowContext(ctx, `SELECT enabled,version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, id).Scan(&enabled, &version, &archivedAt); err == sql.ErrNoRows {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, request.Actor, request.Reason, request.Actor, id, version)
|
||||
} else {
|
||||
if !archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NULL,archived_by='',archive_reason='',enabled=0,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NOT NULL`, request.Actor, id, version)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if archived {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
rule, err := scanAlertRuleLibrary(tx.QueryRowContext(ctx, alertRuleLibrarySelect+`WHERE id=?`, id))
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, request.Actor, action, request.Reason, string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -412,23 +687,46 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
where := "channel='in_app'"
|
||||
conditions := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.UnreadOnly {
|
||||
where += " AND is_read=0"
|
||||
conditions = append(conditions, "n.channel='in_app'", "n.is_read=0")
|
||||
}
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
placeholders := make([]string, len(query.AllowedVINs))
|
||||
for index, vin := range query.AllowedVINs {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
conditions = append(conditions, "e.vin IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if query.DeliveryStatus == "failed" {
|
||||
conditions = append(conditions, "n.delivery_status='failed'")
|
||||
} else if query.DeliveryStatus == "queued" {
|
||||
conditions = append(conditions, "n.delivery_status IN ('created','reserved')")
|
||||
} else if query.DeliveryStatus == "delivered" {
|
||||
conditions = append(conditions, "n.delivery_status NOT IN ('created','reserved','failed')")
|
||||
}
|
||||
if query.Search != "" {
|
||||
like := "%" + query.Search + "%"
|
||||
conditions = append(conditions, "(n.title LIKE ? OR n.content LIKE ? OR n.event_id LIKE ? OR e.plate LIKE ? OR e.vin LIKE ? OR e.protocol LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE `+where).Scan(&total); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,title,content,severity,channel,is_read,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(read_at,'%Y-%m-%dT%H:%i:%s.%fZ'),'') FROM vehicle_alert_notification WHERE `+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, query.Limit, query.Offset)
|
||||
queryArgs := append(append([]any{}, args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationSelect+where+` ORDER BY n.created_at DESC,n.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertNotification{}
|
||||
for rows.Next() {
|
||||
var item AlertNotification
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Read, &item.CreatedAt, &item.ReadAt); err != nil {
|
||||
item, err := scanAlertNotification(rows)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -436,6 +734,42 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
return Page[AlertNotification]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT channel,
|
||||
COALESCE(SUM(CASE WHEN delivery_status IN ('created','reserved') THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' AND GREATEST(COALESCE(attempt_count,1),1)>=? THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN lease_token<>'' AND lease_expires_at>NOW(3) THEN 1 ELSE 0 END),0),
|
||||
COALESCE(DATE_FORMAT(MIN(CASE WHEN delivery_status IN ('created','reserved') THEN created_at END),'`+alertSQLTimestampFormat+`'),'')
|
||||
FROM vehicle_alert_notification
|
||||
WHERE channel<>'in_app'
|
||||
GROUP BY channel
|
||||
ORDER BY FIELD(channel,'sms','email','wecom'),channel`, alertNotificationMaxAttempts)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
for rows.Next() {
|
||||
var channel AlertNotificationChannelHealth
|
||||
if err := rows.Scan(&channel.Channel, &channel.Queued, &channel.Failed, &channel.DeadLetter, &channel.ActiveLeases, &channel.OldestQueuedAt); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
health.Queued += channel.Queued
|
||||
health.Failed += channel.Failed
|
||||
health.DeadLetter += channel.DeadLetter
|
||||
health.ActiveLeases += channel.ActiveLeases
|
||||
if channel.OldestQueuedAt != "" && (health.OldestQueuedAt == "" || channel.OldestQueuedAt < health.OldestQueuedAt) {
|
||||
health.OldestQueuedAt = channel.OldestQueuedAt
|
||||
}
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
return health, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return 0, err
|
||||
@@ -447,10 +781,137 @@ func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, reques
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET is_read=1,read_by=?,read_at=CURRENT_TIMESTAMP(3) WHERE channel='in_app' AND is_read=0 AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
||||
query := `UPDATE vehicle_alert_notification n`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
query += ` JOIN vehicle_alert_event e ON e.id=n.event_id`
|
||||
}
|
||||
query += ` SET n.is_read=1,n.read_by=?,n.read_at=CURRENT_TIMESTAMP(3) WHERE n.channel='in_app' AND n.is_read=0 AND n.id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
vinPlaceholders := make([]string, len(request.AllowedVINs))
|
||||
for index, vin := range request.AllowedVINs {
|
||||
vinPlaceholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
query += ` AND e.vin IN (` + strings.Join(vinPlaceholders, ",") + `)`
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) alertNotificationByID(ctx context.Context, id int64) (AlertNotification, error) {
|
||||
item, err := scanAlertNotification(s.db.QueryRowContext(ctx, alertNotificationSelect+`n.id=?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotification{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationRetryAuditSelect+`notification_id=? ORDER BY requested_at DESC,id DESC LIMIT 20`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanAlertNotificationRetryAudit(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var channel, status string
|
||||
var attemptCount int
|
||||
err = tx.QueryRowContext(ctx, `SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`, id).Scan(&channel, &status, &attemptCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
|
||||
existing, existingErr := scanAlertNotificationRetryAudit(tx.QueryRowContext(ctx, alertNotificationRetryAuditSelect+`idempotency_key=?`, request.IdempotencyKey))
|
||||
if existingErr == nil {
|
||||
if existing.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, loadErr := s.alertNotificationByID(ctx, id)
|
||||
if loadErr != nil {
|
||||
return AlertNotificationRetryResult{}, loadErr
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: existing, Idempotent: true}, nil
|
||||
}
|
||||
if !errors.Is(existingErr, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, existingErr
|
||||
}
|
||||
if status != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
|
||||
nextStatus := "reserved"
|
||||
if channel == "in_app" {
|
||||
nextStatus = "sent"
|
||||
}
|
||||
nextAttempt := attemptCount + 1
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`,
|
||||
id, request.IdempotencyKey, request.Actor, request.Reason, status, nextStatus, nextAttempt)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
auditID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if channel == "in_app" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',attempt_count=?,provider_message_id=CONCAT('in_app:',id,':retry:',?),delivered_at=CURRENT_TIMESTAMP(3),last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, nextAttempt, request.Actor, id)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, request.Actor, id)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, err := s.alertNotificationByID(ctx, id)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
receipt := AlertNotificationRetryAudit{
|
||||
ID: auditID, NotificationID: id, Actor: request.Actor, Reason: request.Reason,
|
||||
PreviousStatus: status, NextStatus: nextStatus, AttemptCount: nextAttempt,
|
||||
RequestedAt: notification.RetryRequestedAt,
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: receipt}, nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
value, supported := alertStreamRuleMetricValue(rule, item, record, mappings)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -75,22 +75,15 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
observedAt = record.ReceivedAt
|
||||
}
|
||||
fingerprint := rule.ID + "|" + record.VIN + "|" + record.Protocol
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
stateUpserts[fingerprint] = alertRuleStateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol, LastValue: normalized, ObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -125,12 +118,12 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
return result, err
|
||||
}
|
||||
unit := alertMetricUnit(rule.Metric)
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -313,7 +306,7 @@ func loadAlertStreamLastTriggered(ctx context.Context, tx *sql.Tx, rules []Alert
|
||||
}
|
||||
|
||||
func loadActiveAlertStreamRules(ctx context.Context, tx *sql.Tx) ([]AlertRule, error) {
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,8 +379,8 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A
|
||||
}
|
||||
|
||||
func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMetadata, mappings alertStreamMetricMapping) alertEvaluationEvidence {
|
||||
longitude, _ := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, _ := alertStreamLocationValue("latitude", record, mappings)
|
||||
longitude, longitudeOK := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, latitudeOK := alertStreamLocationValue("latitude", record, mappings)
|
||||
plate := firstNonEmpty(record.Plate, metadata.Plate)
|
||||
return alertEvaluationEvidence{
|
||||
VIN: record.VIN, Plate: plate, Protocol: record.Protocol, OEM: metadata.OEM, Model: metadata.Model, Company: metadata.Company,
|
||||
@@ -395,10 +388,18 @@ func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMe
|
||||
EventAt: record.EventAt.Format("2006-01-02 15:04:05.999"),
|
||||
ReceivedAt: record.ReceivedAt.Format("2006-01-02 15:04:05.999"),
|
||||
Longitude: longitude, Latitude: latitude,
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
HasLocation: longitudeOK && latitudeOK && validAlertCoordinate(longitude, latitude),
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
}
|
||||
}
|
||||
|
||||
func alertStreamRuleMetricValue(rule AlertRule, item alertEvaluationEvidence, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
return alertRuleMetricValue(rule, item)
|
||||
}
|
||||
return alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
}
|
||||
|
||||
func alertStreamLocationValue(metric string, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if source := mappings[metric][strings.ToUpper(record.Protocol)]; source != "" && !strings.EqualFold(source, "vehicle_realtime_location."+metric) {
|
||||
return alertStreamRawNumber(record.Fields[source])
|
||||
|
||||
@@ -39,14 +39,16 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
record := AlertStreamRecord{
|
||||
Protocol: "GB32960", EventAt: time.Unix(100, 0), ReceivedAt: time.Unix(130, 0),
|
||||
Fields: map[string]json.RawMessage{
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_percent": json.RawMessage(`0.032`),
|
||||
},
|
||||
}
|
||||
mappings := alertStreamMetricMapping{
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"hydrogen_concentration_percent": {"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"},
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("speed_kmh", record, mappings); !ok || value != 82.5 {
|
||||
t.Fatalf("mapped numeric field = %v,%v", value, ok)
|
||||
@@ -60,6 +62,9 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
if value, ok := alertStreamMetricValue("data_delay_sec", record, mappings); !ok || value != 30 {
|
||||
t.Fatalf("derived delay = %v,%v", value, ok)
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("hydrogen_concentration_percent", record, mappings); !ok || value != 0.032 {
|
||||
t.Fatalf("mapped hydrogen concentration = %v,%v", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStreamStateQueriesAreScopedToBatchRulesVehiclesAndProtocols(t *testing.T) {
|
||||
@@ -86,7 +91,7 @@ func TestActiveAlertStreamCommitsRuleEffectsBeforeCheckpointInSameTransaction(t
|
||||
checkpointQuery := `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_stream_checkpoint`).WithArgs("alert-active", record.Topic, 0, int64(8), int64(8), int64(1), int64(1), int64(0), int64(0), int64(0), record.EventAt, record.ReceivedAt, "evt-7", "", "").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
result, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record})
|
||||
@@ -108,7 +113,7 @@ func TestActiveAlertStreamRollsBackCheckpointWhenRuleEvaluationFails(t *testing.
|
||||
record := AlertStreamRecord{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 9, HighWatermark: 10, Protocol: "JT808", VIN: "VIN1", SourceEventID: "evt-9", EventAt: time.Now(), ReceivedAt: time.Now(), Fields: map[string]json.RawMessage{"jt808.location.speed_kmh": json.RawMessage(`10`)}, Valid: true}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectRollback()
|
||||
if _, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record}); err == nil {
|
||||
t.Fatal("rule evaluation failure must abort checkpoint transaction")
|
||||
|
||||
@@ -43,6 +43,36 @@ func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertSQLTimestampsDeclareShanghaiOffset(t *testing.T) {
|
||||
if alertSQLTimestampFormat != "%Y-%m-%dT%H:%i:%s.%f+08:00" {
|
||||
t.Fatalf("unexpected alert timestamp format %q", alertSQLTimestampFormat)
|
||||
}
|
||||
for name, query := range map[string]string{
|
||||
"event": alertEventSelect,
|
||||
"action": alertActionSelect,
|
||||
"rule": alertRuleSelect,
|
||||
"notification": alertNotificationSelect,
|
||||
} {
|
||||
if !strings.Contains(query, alertSQLTimestampFormat) {
|
||||
t.Fatalf("%s query does not use the alert timestamp format", name)
|
||||
}
|
||||
if strings.Contains(query, "%fZ") {
|
||||
t.Fatalf("%s query still labels local database time as UTC", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVehicleEventPublishesCanonicalContract(t *testing.T) {
|
||||
event := normalizeVehicleEvent(AlertEvent{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "exit", Status: "unprocessed"})
|
||||
if event.EventType != "vehicle.geofence.exited" || event.EventCategory != "geofence" || event.ExecutionState != "pending" {
|
||||
t.Fatalf("unexpected canonical event: %#v", event)
|
||||
}
|
||||
recovered := normalizeVehicleEvent(AlertEvent{TriggerType: "offline", Metric: "freshness_sec", Status: "recovered"})
|
||||
if recovered.EventType != "vehicle.connectivity.offline" || recovered.ExecutionState != "recovered" {
|
||||
t.Fatalf("unexpected recovered event: %#v", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
type configuredMetricStore struct {
|
||||
*MockStore
|
||||
definitions []MetricDefinition
|
||||
@@ -145,9 +175,12 @@ func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{AlertNotificationConfig: AlertNotificationConfig{
|
||||
Targets: []AlertNotificationTargetOption{{ID: "night-shift", Label: "夜班负责人", Channels: []string{"sms"}}},
|
||||
Channels: []AlertNotificationChannelCapability{{Channel: "in_app", Label: "站内信", Configured: true}, {Channel: "sms", Label: "短信", Configured: true}},
|
||||
}})
|
||||
truth := true
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: true})
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationTargets: []AlertNotificationTarget{{Channel: "sms", RecipientID: "night-shift", Label: "夜班负责人"}}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -167,6 +200,167 @@ func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRollbackCreatesRevisionAndPreservesEnabledState(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
rules, err := service.AlertRules(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original AlertRule
|
||||
for _, rule := range rules {
|
||||
if rule.ID == "rule-speeding" {
|
||||
original = rule
|
||||
break
|
||||
}
|
||||
}
|
||||
changed := alertRuleInputFromRule(original)
|
||||
changed.Name = "车辆持续超速"
|
||||
changed.Threshold = 96
|
||||
changed.Actor = "editor-a"
|
||||
updated, err := service.SaveAlertRule(t.Context(), changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := service.SetAlertRuleEnabled(t.Context(), updated.ID, AlertRuleEnabledUpdate{Version: updated.Version, Enabled: false, Actor: "operator-b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := service.RollbackAlertRule(t.Context(), disabled.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: disabled.Version, Actor: "reviewer-c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.Name != original.Name || restored.Threshold != original.Threshold {
|
||||
t.Fatalf("target configuration was not restored: %+v", restored)
|
||||
}
|
||||
if restored.Enabled || restored.Version != disabled.Version+1 {
|
||||
t.Fatalf("rollback must preserve disabled state and create a new version: %+v", restored)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(t.Context(), restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) < 4 || revisions[0].Action != "rollback" || revisions[0].Version != restored.Version || revisions[0].Actor != "reviewer-c" {
|
||||
t.Fatalf("rollback revision missing: %+v", revisions)
|
||||
}
|
||||
_, err = service.RollbackAlertRule(t.Context(), restored.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: restored.Version, Actor: "reviewer-c"})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_ROLLBACK_NO_CHANGES" {
|
||||
t.Fatalf("expected no-op rollback rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleLibraryArchivesAndRestoresDisabledRuleWithAudit(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(t.Context(), Principal{Name: "规则管理员", Username: "rule-admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
current, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "current", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Total != 6 || current.Summary.Current != 6 || current.Summary.Archived != 0 {
|
||||
t.Fatalf("unexpected initial library: %+v", current)
|
||||
}
|
||||
|
||||
archived, err := service.SetAlertRuleArchived(admin, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "安全阈值规则已由新版替代", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == "" || archived.ArchivedBy != "rule-admin" || archived.ArchiveReason == "" || archived.Version != 2 {
|
||||
t.Fatalf("archive receipt incomplete: %+v", archived)
|
||||
}
|
||||
|
||||
activeRules, err := service.AlertRules(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range activeRules {
|
||||
if rule.ID == archived.ID {
|
||||
t.Fatalf("archived rule leaked into active evaluator list: %+v", rule)
|
||||
}
|
||||
}
|
||||
archivePage, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "archived", Keyword: "新版替代", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivePage.Total != 1 || archivePage.Summary.Current != 5 || archivePage.Summary.Archived != 1 || archivePage.Items[0].ID != archived.ID {
|
||||
t.Fatalf("unexpected archive page: %+v", archivePage)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(admin, archived.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "archive" || revisions[0].Reason != "安全阈值规则已由新版替代" {
|
||||
t.Fatalf("archive audit missing: %+v", revisions[0])
|
||||
}
|
||||
|
||||
restored, err := service.SetAlertRuleArchived(admin, archived.ID, false, AlertRuleLifecycleRequest{Version: archived.Version, Reason: "恢复复核通知接收人配置", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ArchivedAt != "" || restored.Enabled || restored.Version != 3 {
|
||||
t.Fatalf("restored rule must return disabled at next version: %+v", restored)
|
||||
}
|
||||
revisions, err = service.AlertRuleRevisions(admin, restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "restore" || revisions[0].Reason != "恢复复核通知接收人配置" {
|
||||
t.Fatalf("restore audit missing: %+v", revisions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleGovernanceRequiresAdministrator(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "规则值班员", Role: "operator", UserType: "operator"})
|
||||
if _, err := service.AlertRulePage(operator, AlertRuleQuery{Limit: 10}); err == nil {
|
||||
t.Fatal("operator must not list the governance rule library")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected library permission error: %v", err)
|
||||
}
|
||||
if _, err := service.SetAlertRuleArchived(operator, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "规则已经由新版替代"}); err == nil {
|
||||
t.Fatal("operator must not archive automation rules")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected archive permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionRoutes(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v2/alerts/rules/rule-speeding/revisions", nil))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"version":2`) {
|
||||
t.Fatalf("revision route status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
rollback := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/rules/rule-speeding/rollback", strings.NewReader(`{"targetVersion":2,"currentVersion":2}`)))
|
||||
if rollback.Code != http.StatusBadRequest || !strings.Contains(rollback.Body.String(), "ALERT_RULE_ROLLBACK_TARGET_INVALID") {
|
||||
t.Fatalf("rollback validation status=%d body=%s", rollback.Code, rollback.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionsParsesAuditSnapshots(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT rule_id,rule_version,actor,action,COALESCE\\(reason,''\\),snapshot_json").WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"rule_id", "rule_version", "actor", "action", "reason", "snapshot_json", "created_at"}).AddRow("rule-1", 3, "admin-a", "update", "", `{"id":"rule-1","name":"超速","severity":"major","valueType":"numeric","metric":"speed_kmh","operator":"gt","threshold":90,"scopeProtocols":["JT808"],"scopeVins":[],"scopeOems":[],"scopeModels":[],"scopeCompanies":[],"notificationChannels":["in_app"],"enabled":true}`, "2026-07-23T10:00:00.000000+08:00"))
|
||||
revisions, err := store.AlertRuleRevisions(t.Context(), "rule-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) != 1 || revisions[0].Snapshot.Version != 3 || revisions[0].Snapshot.Threshold != 90 || revisions[0].Snapshot.UpdatedBy != "admin-a" {
|
||||
t.Fatalf("unexpected revisions: %+v", revisions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
before, err := service.AlertSummary(t.Context(), AlertQuery{})
|
||||
@@ -190,6 +384,139 @@ func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsFiltersTheCompleteRangeBeforePagination(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
store.alertNotifications = append(store.alertNotifications, AlertNotification{
|
||||
ID: 999, EventID: "event-delivery-failed", Title: "夜班短信失败", Content: "短信网关超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", DeliveryStatus: "failed",
|
||||
VehiclePlate: "浙A·失败", VehicleVIN: "VIN-DELIVERY-FAILED", Protocol: "JT808",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
service := NewService(store)
|
||||
|
||||
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "VIN-DELIVERY-FAILED", DeliveryStatus: "failed", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 999 {
|
||||
t.Fatalf("server-wide notification filter mismatch: %+v", page)
|
||||
}
|
||||
delivered, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "短信网关超时", DeliveryStatus: "delivered", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivered.Total != 0 || len(delivered.Items) != 0 {
|
||||
t.Fatalf("failed delivery leaked into delivered filter: %+v", delivered)
|
||||
}
|
||||
if _, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{DeliveryStatus: "unknown", Limit: 20}); err == nil {
|
||||
t.Fatal("invalid delivery status should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationRetryIsPermissionedIdempotentAndAudited(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
||||
request := AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 2,
|
||||
Reason: "已确认短信网关恢复",
|
||||
IdempotencyKey: "notification-retry-10-attempt-3",
|
||||
}
|
||||
result, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Idempotent || result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 3 || result.Notification.RetryRequestedBy != "operator-a" {
|
||||
t.Fatalf("unexpected retry result: %+v", result)
|
||||
}
|
||||
if result.Receipt.NotificationID != 10 || result.Receipt.AttemptCount != 3 || result.Receipt.Reason != request.Reason {
|
||||
t.Fatalf("unexpected retry receipt: %+v", result.Receipt)
|
||||
}
|
||||
repeated, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repeated.Idempotent || repeated.Notification.AttemptCount != 3 || repeated.Receipt.ID != result.Receipt.ID {
|
||||
t.Fatalf("same key must reuse the original retry: %+v", repeated)
|
||||
}
|
||||
audit, err := service.AlertNotificationRetryAudits(operator, 10)
|
||||
if err != nil || len(audit) != 1 || audit[0].ID != result.Receipt.ID {
|
||||
t.Fatalf("retry audit mismatch: %+v err=%v", audit, err)
|
||||
}
|
||||
viewer := WithPrincipal(t.Context(), Principal{Name: "只读审计员", Username: "viewer-a", Role: "viewer", UserType: "operator"})
|
||||
if _, err := service.RetryAlertNotification(viewer, 10, request); err == nil {
|
||||
t.Fatal("read-only role must not retry notifications")
|
||||
}
|
||||
if _, err := service.RetryAlertNotification(operator, 10, AlertNotificationRetryRequest{ExpectedAttemptCount: 2, Reason: "短", IdempotencyKey: "notification-retry-invalid"}); err == nil {
|
||||
t.Fatal("retry reason must be auditable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsScopeCustomerToGrantedVehicles(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
allowed := WithPrincipal(t.Context(), Principal{
|
||||
Name: "客户账号", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LFP23A98V2P012345"},
|
||||
})
|
||||
page, err := service.AlertNotifications(allowed, AlertNotificationQuery{DeliveryStatus: "failed", Limit: 20})
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 10 {
|
||||
t.Fatalf("customer notification scope mismatch: %+v err=%v", page, err)
|
||||
}
|
||||
denied := WithPrincipal(t.Context(), Principal{Name: "无授权客户", Role: "customer", UserType: "customer"})
|
||||
empty, err := service.AlertNotifications(denied, AlertNotificationQuery{Limit: 20})
|
||||
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
|
||||
t.Fatalf("customer without grants must receive an empty scope: %+v err=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionAlertNotificationRetryQueuesOneAuditedAttempt(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"channel", "delivery_status", "attempt_count"}).AddRow("sms", "failed", 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationRetryAuditSelect + `idempotency_key=?`)).
|
||||
WithArgs("notification-retry-42-attempt-2").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "notification_id", "actor", "reason", "previous_status", "next_status", "attempt_count", "requested_at"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`)).
|
||||
WithArgs(int64(42), "notification-retry-42-attempt-2", "operator-a", "短信网关已经恢复", "failed", "reserved", 2).
|
||||
WillReturnResult(sqlmock.NewResult(81, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`)).
|
||||
WithArgs(2, "operator-a", int64(42)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
notificationColumns := []string{"id", "event_id", "title", "content", "severity", "channel", "recipient", "recipient_ref", "delivery_status", "attempt_count", "provider_message_id", "is_read", "created_at", "delivered_at", "read_at", "last_error", "last_attempt_at", "retry_requested_by", "retry_requested_at", "plate", "vin", "protocol"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationSelect + `n.id=?`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows(notificationColumns).AddRow(42, "event-42", "低电量短信", "SOC 低于 20%", "major", "sms", "夜班负责人", "night-shift", "reserved", 2, "", false, "2026-07-23T10:00:00+08:00", "", "", "provider_timeout", "2026-07-23T10:05:00+08:00", "operator-a", "2026-07-23T10:05:00+08:00", "粤A0042", "VIN42", "GB32960"))
|
||||
|
||||
result, err := store.RetryAlertNotification(t.Context(), 42, AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 1,
|
||||
Reason: "短信网关已经恢复",
|
||||
IdempotencyKey: "notification-retry-42-attempt-2",
|
||||
Actor: "operator-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 2 || result.Receipt.ID != 81 || result.Receipt.NextStatus != "reserved" {
|
||||
t.Fatalf("unexpected production retry result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
|
||||
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
|
||||
@@ -228,10 +555,10 @@ func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
|
||||
}
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
|
||||
}
|
||||
}
|
||||
@@ -321,12 +648,12 @@ func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "trigger_type", "fence_name", "fence_longitude", "fence_latitude", "fence_radius_m", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "notification_targets_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "metric", "", 0.0, 0.0, 0.0, "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, `[{"channel":"in_app","recipientId":"platform-operators","label":"平台值班组"}]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", "", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
|
||||
if err != nil {
|
||||
@@ -380,7 +707,7 @@ func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
|
||||
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
|
||||
}
|
||||
if len(args) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
if len(args) != 23 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +735,114 @@ func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleValidationAcceptsStreamMappedHydrogenMetric(t *testing.T) {
|
||||
var hydrogen MetricDefinition
|
||||
for _, definition := range metricDefinitions() {
|
||||
if definition.Key == "hydrogen_concentration_percent" {
|
||||
hydrogen = definition
|
||||
break
|
||||
}
|
||||
}
|
||||
if hydrogen.Key == "" {
|
||||
t.Fatal("hydrogen concentration metric missing from catalog")
|
||||
}
|
||||
input := AlertRuleInput{
|
||||
Name: "氢气浓度报警提示", Severity: "critical", ValueType: "numeric",
|
||||
Metric: hydrogen.Key, Operator: "gt", Threshold: 0.05,
|
||||
}
|
||||
if err := validateAlertRule(input, hydrogen); err != nil {
|
||||
t.Fatalf("stream-mapped hydrogen rule rejected: %v", err)
|
||||
}
|
||||
if unit := alertMetricUnit(hydrogen.Key); unit != "%" {
|
||||
t.Fatalf("hydrogen alert unit=%q want %%", unit)
|
||||
}
|
||||
|
||||
unmapped := hydrogen
|
||||
unmapped.Key = "unmapped_dynamic_metric"
|
||||
unmapped.SourceFields = nil
|
||||
input.Metric = unmapped.Key
|
||||
err := validateAlertRule(input, unmapped)
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_METRIC_UNSUPPORTED" {
|
||||
t.Fatalf("unmapped dynamic metric should remain rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAutomationTriggerValidationAndNormalization(t *testing.T) {
|
||||
geofence := AlertRuleInput{
|
||||
Name: "离开临港停车场", TriggerType: "geofence", FenceName: "临港停车场",
|
||||
FenceLongitude: 121.9, FenceLatitude: 30.9, FenceRadiusM: 500,
|
||||
Severity: "critical", Operator: "exit", RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"},
|
||||
}
|
||||
normalizeAlertRuleTrigger(&geofence)
|
||||
if geofence.Metric != "geofence_distance_m" || geofence.ValueType != "numeric" || geofence.Threshold != 500 {
|
||||
t.Fatalf("geofence normalization incomplete: %+v", geofence)
|
||||
}
|
||||
if err := validateAlertRule(geofence); err != nil {
|
||||
t.Fatalf("valid geofence rejected: %v", err)
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808", "GB32960"}
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("multi-source geofence should be rejected to prevent coordinate drift")
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808"}
|
||||
geofence.FenceRadiusM = 20
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("unsafe geofence radius should be rejected")
|
||||
}
|
||||
|
||||
stationary := AlertRuleInput{Name: "长时间不动", TriggerType: "stationary", Severity: "major", Operator: "lte", Threshold: 1, DurationSec: 1800}
|
||||
normalizeAlertRuleTrigger(&stationary)
|
||||
if err := validateAlertRule(stationary); err != nil {
|
||||
t.Fatalf("valid stationary rule rejected: %v", err)
|
||||
}
|
||||
offline := AlertRuleInput{Name: "长时间离线", TriggerType: "offline", Severity: "major", Operator: "gt", Threshold: 36000}
|
||||
normalizeAlertRuleTrigger(&offline)
|
||||
if err := validateAlertRule(offline); err != nil || offline.RecoveryThreshold != 36000 {
|
||||
t.Fatalf("offline rule did not normalize: rule=%+v err=%v", offline, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeofenceDistanceAndDirectionalTransitions(t *testing.T) {
|
||||
rule := AlertRule{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "enter", FenceLongitude: 121.4737, FenceLatitude: 31.2304, FenceRadiusM: 500}
|
||||
outside := alertEvaluationEvidence{Longitude: 121.49, Latitude: 31.2304, HasLocation: true}
|
||||
inside := alertEvaluationEvidence{Longitude: 121.4738, Latitude: 31.2304, HasLocation: true}
|
||||
outsideDistance, ok := alertRuleMetricValue(rule, outside)
|
||||
if !ok || outsideDistance <= 500 {
|
||||
t.Fatalf("outside distance=%f ok=%t", outsideDistance, ok)
|
||||
}
|
||||
insideDistance, ok := alertRuleMetricValue(rule, inside)
|
||||
if !ok || insideDistance >= 500 {
|
||||
t.Fatalf("inside distance=%f ok=%t", insideDistance, ok)
|
||||
}
|
||||
matched, state, stateful := alertRuleMatches(rule, insideDistance, alertRuleState{LastValue: 0}, true)
|
||||
if !matched || !stateful || state != 1 {
|
||||
t.Fatalf("enter transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
rule.Operator = "exit"
|
||||
matched, state, stateful = alertRuleMatches(rule, outsideDistance, alertRuleState{LastValue: 1}, true)
|
||||
if !matched || !stateful || state != 0 {
|
||||
t.Fatalf("exit transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationPolicySupportsRecordOnlyAndHighPriority(t *testing.T) {
|
||||
if channels := normalizeAlertChannels(nil); len(channels) != 0 {
|
||||
t.Fatalf("record-only automation unexpectedly created channels: %+v", channels)
|
||||
}
|
||||
if channels := normalizeAlertChannels([]string{"sms"}); len(channels) != 2 || channels[0] != "in_app" {
|
||||
t.Fatalf("reserved external channel should retain in-app evidence: %+v", channels)
|
||||
}
|
||||
rule := AlertRule{Name: "车辆离开围栏", TriggerType: "geofence", FenceName: "临港停车场", Operator: "exit", Severity: "critical"}
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "沪A00001F"}
|
||||
if title := alertNotificationTitle(rule); !strings.HasPrefix(title, "【高优先级】") {
|
||||
t.Fatalf("critical notification title=%q", title)
|
||||
}
|
||||
if content := alertNotificationContent(rule, item, 800, "m"); !strings.Contains(content, "离开电子围栏") || !strings.Contains(content, "临港停车场") {
|
||||
t.Fatalf("geofence notification content=%q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
|
||||
definitions := metricDefinitions()
|
||||
for index := range definitions {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const vehicleBusinessRelationSelect = `SELECT
|
||||
s.source_version,
|
||||
CAST(s.vehicle_id AS CHAR),
|
||||
s.vin,
|
||||
s.plate_number,
|
||||
CAST(s.customer_id AS CHAR),
|
||||
s.customer_name,
|
||||
COALESCE(CAST(s.contract_id AS CHAR), ''),
|
||||
s.contract_code,
|
||||
s.project_name,
|
||||
s.department_id,
|
||||
s.department_name,
|
||||
s.responsible_user_id,
|
||||
s.responsible_user_name,
|
||||
s.operation_status,
|
||||
COALESCE(DATE_FORMAT(s.scope_start_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.source_updated_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.published_at, '%Y-%m-%d %H:%i:%s'), '')
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s
|
||||
ON BINARY s.source_version = BINARY st.active_version
|
||||
WHERE st.id = 1 AND BINARY s.vin = BINARY ?
|
||||
LIMIT 1`
|
||||
|
||||
func (s *ProductionStore) ensureBusinessScopeSchema(ctx context.Context) error {
|
||||
return s.businessScopeSchema.ensure(func() error {
|
||||
var tables int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('business_scope_state','business_customer_vehicle_scope')`).Scan(&tables); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables != 2 {
|
||||
return fmt.Errorf("business scope schema unavailable; apply deploy/migrations/012_business_scope_projection.sql and 016_business_scope_dimensions.sql")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessRelation(ctx context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
relation := VehicleBusinessRelation{SourceSystem: "oneos"}
|
||||
err := s.db.QueryRowContext(ctx, vehicleBusinessRelationSelect, vin).Scan(
|
||||
&relation.SourceVersion,
|
||||
&relation.VehicleID,
|
||||
&relation.VIN,
|
||||
&relation.PlateNumber,
|
||||
&relation.CustomerID,
|
||||
&relation.CustomerName,
|
||||
&relation.ContractID,
|
||||
&relation.ContractCode,
|
||||
&relation.ProjectName,
|
||||
&relation.DepartmentID,
|
||||
&relation.DepartmentName,
|
||||
&relation.ResponsibleUserID,
|
||||
&relation.ResponsibleUserName,
|
||||
&relation.OperationStatus,
|
||||
&relation.ScopeStartAt,
|
||||
&relation.SourceUpdatedAt,
|
||||
&relation.PublishedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return VehicleBusinessRelation{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
return relation, true, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessFilters(ctx context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
where := []string{"st.id = 1"}
|
||||
args := []any{}
|
||||
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
s.department_id,s.department_name,s.responsible_user_id,s.responsible_user_name,
|
||||
CAST(s.customer_id AS CHAR),s.customer_name,s.operation_status,s.vin
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
|
||||
WHERE `+strings.Join(where, " AND "), args...)
|
||||
if err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type optionKey struct{ value, label string }
|
||||
departments := map[optionKey]map[string]bool{}
|
||||
responsibleUsers := map[optionKey]map[string]bool{}
|
||||
customers := map[optionKey]map[string]bool{}
|
||||
statuses := map[optionKey]map[string]bool{}
|
||||
add := func(target map[optionKey]map[string]bool, value, label, vin string) {
|
||||
value, label = strings.TrimSpace(value), strings.TrimSpace(label)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if label == "" {
|
||||
label = value
|
||||
}
|
||||
key := optionKey{value: value, label: label}
|
||||
if target[key] == nil {
|
||||
target[key] = map[string]bool{}
|
||||
}
|
||||
target[key][vin] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var departmentID, departmentName, responsibleID, responsibleName, customerID, customerName, status, vin string
|
||||
if err := rows.Scan(&departmentID, &departmentName, &responsibleID, &responsibleName, &customerID, &customerName, &status, &vin); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
add(departments, departmentID, departmentName, vin)
|
||||
add(responsibleUsers, responsibleID, responsibleName, vin)
|
||||
add(customers, customerID, customerName, vin)
|
||||
add(statuses, status, status, vin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
toOptions := func(source map[optionKey]map[string]bool) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for key, vins := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: key.value, Label: key.label, Count: len(vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Label == result[j].Label {
|
||||
return result[i].Value < result[j].Value
|
||||
}
|
||||
return result[i].Label < result[j].Label
|
||||
})
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: toOptions(departments), ResponsibleUsers: toOptions(responsibleUsers),
|
||||
Customers: toOptions(customers), Statuses: toOptions(statuses),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestVehicleBusinessRelationReadsOnlyTheActiveSnapshotAndKeepsBigIntIDsAsStrings(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM information_schema\.tables`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleBusinessRelationSelect)).
|
||||
WithArgs("LTEST000000000001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"source_version", "vehicle_id", "vin", "plate_number", "customer_id", "customer_name",
|
||||
"contract_id", "contract_code", "project_name", "department_id", "department_name",
|
||||
"responsible_user_id", "responsible_user_name", "operation_status", "scope_start_at",
|
||||
"source_updated_at", "published_at",
|
||||
}).AddRow(
|
||||
"oneos-v1:abc", "9223372036854775806", "LTEST000000000001", "粤A12345",
|
||||
"9223372036854775805", "示例客户", "9223372036854775804", "HT-001", "示范项目",
|
||||
"20", "华南运营部", "30", "张经理", "运营中",
|
||||
"2026-07-01 08:00:00", "2026-07-24 08:00:00", "2026-07-24 08:02:00",
|
||||
))
|
||||
|
||||
relation, found, err := store.VehicleBusinessRelation(context.Background(), "LTEST000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || relation.SourceSystem != "oneos" || relation.CustomerName != "示例客户" {
|
||||
t.Fatalf("unexpected business relation: %+v", relation)
|
||||
}
|
||||
if relation.VehicleID != "9223372036854775806" || relation.CustomerID != "9223372036854775805" {
|
||||
t.Fatalf("BIGINT identifiers must remain exact strings: %+v", relation)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleDetailIncludesOneOSBusinessRelation(t *testing.T) {
|
||||
detail, err := NewService(NewMockStore()).VehicleDetail(context.Background(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.BusinessRelation == nil {
|
||||
t.Fatal("vehicle detail omitted the OneOS business relation")
|
||||
}
|
||||
if detail.BusinessRelation.CustomerName != "岭牛示范客户" || detail.BusinessRelation.ContractCode != "HT-2026-001" {
|
||||
t.Fatalf("unexpected business relation: %+v", detail.BusinessRelation)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage/summary", h.handleVehicleCoverageSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicles/business-filters", h.handleVehicleBusinessFilters)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
||||
@@ -43,7 +45,9 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||
@@ -64,21 +68,48 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
|
||||
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/summary", h.handleReconciliationSummary)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/assignees", h.handleReconciliationAssignees)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues", h.handleReconciliationIssues)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/export", h.handleReconciliationIssuesExport)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-actions", h.handleReconciliationBatchAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-assignments", h.handleReconciliationBatchAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-archive", h.handleReconciliationBatchArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-restore", h.handleReconciliationBatchRestore)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/issues/{id}", h.handleReconciliationIssue)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/actions", h.handleReconciliationAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/assignment", h.handleReconciliationAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/archive", h.handleReconciliationArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/restore", h.handleReconciliationRestore)
|
||||
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
|
||||
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
|
||||
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/metrics", h.handleHistoryMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/query", h.handleHistoryData)
|
||||
h.mux.HandleFunc("GET /api/v2/history/series", h.handleHistorySeries)
|
||||
h.mux.HandleFunc("GET /api/v2/history/preferences", h.handleHistoryPreferences)
|
||||
h.mux.HandleFunc("PUT /api/v2/history/preferences", h.handleUpdateHistoryPreferences)
|
||||
h.mux.HandleFunc("POST /api/v2/exports", h.handleCreateHistoryExport)
|
||||
h.mux.HandleFunc("GET /api/v2/exports", h.handleListHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/page", h.handleListHistoryExportsPage)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/preview", h.handlePreviewHistoryExportCleanup)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/audit", h.handleHistoryExportCleanupAudit)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup", h.handleCleanupHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/automation", h.handleHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/cleanup/automation", h.handleUpdateHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/approve", h.handleApproveHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/reject", h.handleRejectHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/retry", h.handleRetryHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/batch", h.handleBatchHistoryExports)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/cancel", h.handleCancelHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/rebuild", h.handleRebuildHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/archive", h.handleArchiveHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/restore", h.handleRestoreHistoryExport)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/{id}/cleanup-protection", h.handleHistoryExportCleanupProtection)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/{id}/download", h.handleDownloadHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/access/summary", h.handleAccessSummary)
|
||||
h.mux.HandleFunc("POST /api/v2/access/vehicles", h.handleAccessVehicles)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities", h.handleAccessUnresolvedIdentities)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities/{id}/claim", h.handleClaimAccessIdentity)
|
||||
h.mux.HandleFunc("GET /api/v2/access/thresholds", h.handleAccessThresholds)
|
||||
h.mux.HandleFunc("PUT /api/v2/access/thresholds", h.handleUpdateAccessThresholds)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/summary", h.handleAlertSummary)
|
||||
@@ -86,11 +117,30 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/events/{id}", h.handleAlertEvent)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/events/{id}/actions", h.handleAlertAction)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules", h.handleAlertRules)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/library", h.handleAlertRuleLibrary)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}/enabled", h.handleAlertRuleEnabled)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/{id}/revisions", h.handleAlertRuleRevisions)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/rollback", h.handleAlertRuleRollback)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/archive", h.handleArchiveAlertRule)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/restore", h.handleRestoreAlertRule)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notification-config", h.handleAlertNotificationConfig)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/health", h.handleAlertNotificationDeliveryHealth)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications", h.handleAlertNotifications)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/{id}/retry", h.handleAlertNotificationRetry)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/{id}/retry-audit", h.handleAlertNotificationRetryAudit)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationConfig(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationDeliveryHealth(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationDeliveryHealth(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,6 +157,29 @@ func (h *Handler) handleReconciliationIssues(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignees(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationAssignees(r.Context(), r.URL.Query().Get("search"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssuesExport(w http.ResponseWriter, r *http.Request) {
|
||||
var query ReconciliationQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
return
|
||||
}
|
||||
file, err := h.service.ExportReconciliationIssues(r.Context(), query)
|
||||
if err != nil {
|
||||
h.write(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="quality-issues.csv"; filename*=UTF-8''`+url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Name", url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Count", strconv.Itoa(file.RowCount))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(file.Content)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssue(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationIssue(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -121,6 +194,67 @@ func (h *Handler) handleReconciliationAction(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.AssignReconciliationIssue(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchAssignReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchUpdateReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetReconciliationIssueArchived(r.Context(), r.PathValue("id"), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationBatchLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchSetReconciliationIssuesArchived(r.Context(), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessUnresolvedIdentityQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -130,6 +264,16 @@ func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *htt
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleClaimAccessIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
var input AccessIdentityClaimInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.ClaimAccessIdentity(r.Context(), r.PathValue("id"), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleProfile(r.Context(), r.PathValue("vin"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -224,6 +368,16 @@ func (h *Handler) handleAlertRules(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
query := AlertRuleQuery{
|
||||
Keyword: r.URL.Query().Get("keyword"), Status: r.URL.Query().Get("status"),
|
||||
Protocol: r.URL.Query().Get("protocol"), Lifecycle: r.URL.Query().Get("lifecycle"),
|
||||
Limit: parsePositive(r.URL.Query().Get("limit"), 10), Offset: parsePositive(r.URL.Query().Get("offset"), 0),
|
||||
}
|
||||
data, err := h.service.AlertRulePage(r.Context(), query)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSaveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var input AlertRuleInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
@@ -247,11 +401,52 @@ func (h *Handler) handleAlertRuleEnabled(w http.ResponseWriter, r *http.Request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertRuleRevisions(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRollback(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleRollbackRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.RollbackAlertRule(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), true, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), false, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 20)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
unread := strings.EqualFold(r.URL.Query().Get("unreadOnly"), "true") || r.URL.Query().Get("unreadOnly") == "1"
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{UnreadOnly: unread, Limit: limit, Offset: offset})
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{
|
||||
UnreadOnly: unread,
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
||||
DeliveryStatus: strings.TrimSpace(r.URL.Query().Get("deliveryStatus")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
@@ -265,6 +460,30 @@ func (h *Handler) handleAlertNotificationsRead(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, map[string]int{"updated": count}, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetry(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
var request AlertNotificationRetryRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryAlertNotification(r.Context(), id, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetryAudit(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
data, err := h.service.AlertNotificationRetryAudits(r.Context(), id)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessSummary(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -324,6 +543,129 @@ func (h *Handler) handleListHistoryExports(w http.ResponseWriter, r *http.Reques
|
||||
h.write(w, r, h.service.ListHistoryExports(r.Context()), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListHistoryExportsPage(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 10)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
h.write(w, r, h.service.ListHistoryExportsPage(r.Context(), HistoryExportQuery{
|
||||
Search: r.URL.Query().Get("search"), Status: r.URL.Query().Get("status"), Scope: r.URL.Query().Get("scope"), OwnerScope: r.URL.Query().Get("ownerScope"), Sort: r.URL.Query().Get("sort"), Limit: limit, Offset: offset,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePreviewHistoryExportCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.PreviewHistoryExportCleanup(r.Context(), HistoryExportCleanupQuery{
|
||||
OlderThanDays: parsePositive(r.URL.Query().Get("olderThanDays"), 180),
|
||||
OwnerScope: r.URL.Query().Get("ownerScope"),
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAudit(r.Context(), parsePositive(r.URL.Query().Get("limit"), 20))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCleanupHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CleanupHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAutomation(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationPolicyRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryExportCleanupAutomation(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleApproveHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ApproveHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRejectHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RejectHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRetryHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryPreferences(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
var input HistoryPreferences
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryPreferences(r.Context(), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCancelHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.CancelHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRebuildHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.RebuildHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), true)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), false)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupProtection(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupProtectionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetHistoryExportCleanupProtection(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBatchHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportBatchRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDownloadHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
path, name, err := h.service.HistoryExportFile(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -401,6 +743,11 @@ func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleBusinessFilters(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleBusinessFilters(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleServiceSummary(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
@@ -487,6 +834,15 @@ func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDailyMileagePost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.DailyMileage(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.MileageSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
@@ -497,6 +853,62 @@ func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.MileageStatistics(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
||||
defer r.Body.Close()
|
||||
var query MileageQuery
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&query); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "里程查询 JSON 解析失败", err.Error(), traceID(r))
|
||||
return MileageQuery{}, false
|
||||
}
|
||||
return query, true
|
||||
}
|
||||
|
||||
func mileageQueryValues(query MileageQuery) url.Values {
|
||||
values := url.Values{}
|
||||
if query.DateFrom != "" {
|
||||
values.Set("dateFrom", query.DateFrom)
|
||||
}
|
||||
if query.DateTo != "" {
|
||||
values.Set("dateTo", query.DateTo)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
values.Set("keyword", query.Keyword)
|
||||
}
|
||||
if len(query.VINs) > 0 {
|
||||
values.Set("vins", strings.Join(query.VINs, ","))
|
||||
}
|
||||
if query.VehicleScope != "" {
|
||||
values.Set("vehicleScope", query.VehicleScope)
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
values.Set("protocol", query.Protocol)
|
||||
}
|
||||
if len(query.Protocols) > 0 {
|
||||
values.Set("protocols", strings.Join(query.Protocols, ","))
|
||||
}
|
||||
if query.Deduplicate {
|
||||
values.Set("deduplicate", "1")
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
values.Set("limit", strconv.Itoa(query.Limit))
|
||||
}
|
||||
if query.Offset > 0 {
|
||||
values.Set("offset", strconv.Itoa(query.Offset))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -29,6 +29,45 @@ func TestHandlerDashboardSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRetriesFailedNotificationAndReturnsPersistentAudit(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
principal := Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"}
|
||||
retry := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/notifications/10/retry", strings.NewReader(`{"expectedAttemptCount":2,"reason":"已确认短信网关恢复","idempotencyKey":"notification-retry-10-attempt-3"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), principal))
|
||||
handler.ServeHTTP(retry, request)
|
||||
if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `"attemptCount":3`) || !strings.Contains(retry.Body.String(), `"nextStatus":"reserved"`) {
|
||||
t.Fatalf("retry status=%d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
audit := httptest.NewRecorder()
|
||||
auditRequest := httptest.NewRequest(http.MethodGet, "/api/v2/alerts/notifications/10/retry-audit", nil)
|
||||
auditRequest = auditRequest.WithContext(WithPrincipal(auditRequest.Context(), principal))
|
||||
handler.ServeHTTP(audit, auditRequest)
|
||||
if audit.Code != http.StatusOK || !strings.Contains(audit.Body.String(), `"reason":"已确认短信网关恢复"`) {
|
||||
t.Fatalf("audit status=%d body=%s", audit.Code, audit.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryCleanupAutomationExposesVersionedApprovalPolicy(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
handler := NewHandler(service)
|
||||
admin := WithPrincipal(context.Background(), Principal{SubjectID: "admin-1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
get := httptest.NewRecorder()
|
||||
handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v2/exports/cleanup/automation", nil).WithContext(admin))
|
||||
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), `"revision":1`) || !strings.Contains(get.Body.String(), `"approvalWindowHours":48`) {
|
||||
t.Fatalf("automation status=%d body=%s", get.Code, get.Body.String())
|
||||
}
|
||||
|
||||
update := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v2/exports/cleanup/automation", strings.NewReader(`{"enabled":true,"olderThanDays":180,"intervalDays":7,"approvalWindowHours":24,"expectedRevision":1}`)).WithContext(admin)
|
||||
handler.ServeHTTP(update, request)
|
||||
if update.Code != http.StatusOK || !strings.Contains(update.Body.String(), `"enabled":true`) || !strings.Contains(update.Body.String(), `"revision":2`) || !strings.Contains(update.Body.String(), `"status":"no_candidates"`) {
|
||||
t.Fatalf("automation update status=%d body=%s", update.Code, update.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
@@ -59,6 +98,111 @@ func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchReviewReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-actions", strings.NewReader(`{
|
||||
"items":[
|
||||
{"id":"reconciliation-demo-position","version":1},
|
||||
{"id":"missing-issue","version":1}
|
||||
],
|
||||
"status":"fixed",
|
||||
"note":"已核对定位设备与原始报文,修复结果通过复测"
|
||||
}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维乙", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch review status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"id":"reconciliation-demo-position"`, `"actor":"运维乙"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch review response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationAssignmentAndOwnerFilter(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/assignment", strings.NewReader(fmt.Sprintf(`{"version":1,"assignee":"定位运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"assignee":"定位运维组"`) || !strings.Contains(response.Body.String(), `"action":"assign"`) {
|
||||
t.Fatalf("assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","owner":"assigned","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"assignee":"定位运维组"`) {
|
||||
t.Fatalf("assigned filter status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchAssignmentReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
dueAt := time.Now().Add(8 * time.Hour).UTC().Format(time.RFC3339)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-assignments", strings.NewReader(fmt.Sprintf(`{"items":[{"id":"reconciliation-demo-position","version":1},{"id":"missing-issue","version":1}],"assignee":"夜班运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "值班主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"assignee":"夜班运维组"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch assignment response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationDirectoryAndFilteredExport(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
directory := httptest.NewRecorder()
|
||||
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/assignees?search=current", nil)
|
||||
directoryRequest = directoryRequest.WithContext(WithPrincipal(directoryRequest.Context(), Principal{Name: "Current Operator", Username: "current", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(directory, directoryRequest)
|
||||
if directory.Code != http.StatusOK || !strings.Contains(directory.Body.String(), `"current":true`) || !strings.Contains(directory.Body.String(), `"name":"Current Operator"`) {
|
||||
t.Fatalf("directory status=%d body=%s", directory.Code, directory.Body.String())
|
||||
}
|
||||
|
||||
exported := httptest.NewRecorder()
|
||||
handler.ServeHTTP(exported, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/export", strings.NewReader(`{"status":"active","owner":"unassigned"}`)))
|
||||
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "text/csv; charset=utf-8" || exported.Header().Get("X-Export-Count") != "1" || !strings.Contains(exported.Body.String(), "多来源实时位置漂移") {
|
||||
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationArchiveAndRestoreExposeAuditReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
admin := Principal{Name: "质量治理管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}
|
||||
|
||||
archive := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/archive", strings.NewReader(`{"version":2,"reason":"已完成周期复核,转入审计归档"}`))
|
||||
handler.ServeHTTP(archive, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if archive.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", archive.Code, archive.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"archivedBy":"质量治理管理员"`, `"archiveReason":"已完成周期复核,转入审计归档"`, `"action":"archive"`, `"version":3`} {
|
||||
if !strings.Contains(archive.Body.String(), expected) {
|
||||
t.Fatalf("archive response missing %s: %s", expected, archive.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"scope":"archived","keyword":"周期复核","status":"all","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"id":"reconciliation-demo-source"`) {
|
||||
t.Fatalf("archive list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
|
||||
restore := httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/restore", strings.NewReader(`{"version":3,"reason":"新的来源证据需要重新核对"}`))
|
||||
handler.ServeHTTP(restore, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if restore.Code != http.StatusOK || !strings.Contains(restore.Body.String(), `"action":"restore"`) || !strings.Contains(restore.Body.String(), `"archivedAt":""`) {
|
||||
t.Fatalf("restore status=%d body=%s", restore.Code, restore.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct {
|
||||
@@ -119,6 +263,29 @@ func TestHandlerV2AccessManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClaimsAccessIdentityWithAuthenticatedActor(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
claim := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities/mock-unresolved-jt808/claim", strings.NewReader(`{"vin":"LNXNEGRR7SR318212","note":"已核对来源注册信息与车辆档案"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "治理管理员", Role: "admin", UserType: "operator"}))
|
||||
handler.ServeHTTP(claim, request)
|
||||
if claim.Code != http.StatusOK {
|
||||
t.Fatalf("claim status=%d body=%s", claim.Code, claim.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"vin":"LNXNEGRR7SR318212"`, `"claimedBy":"治理管理员"`, `"profileComplete":false`, `"auditId":`} {
|
||||
if !strings.Contains(claim.Body.String(), expected) {
|
||||
t.Fatalf("claim response missing %s: %s", expected, claim.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
queue := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queue, httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities", strings.NewReader(`{"limit":20}`)))
|
||||
if queue.Code != http.StatusOK || !strings.Contains(queue.Body.String(), `"total":0`) {
|
||||
t.Fatalf("claimed identity must leave unresolved queue: status=%d body=%s", queue.Code, queue.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2TrackPlayback(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -166,14 +333,46 @@ func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySeriesGrainAndBounds(t *testing.T) {
|
||||
func TestHandlerV2HistoryReturnsPerVehicleQueryReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,NO_HISTORY_VEHICLE&category=location&limit=10", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data HistoryDataResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode history response: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Data.Summary.RequestedVehicleCount != 2 || len(body.Data.Summary.Vehicles) != 2 {
|
||||
t.Fatalf("history query receipt must preserve both requested vehicles: %+v", body.Data.Summary)
|
||||
}
|
||||
if body.Data.Summary.Vehicles[0].Status != "matched" || body.Data.Summary.Vehicles[0].RowCount == 0 {
|
||||
t.Fatalf("known vehicle should expose matched row evidence: %+v", body.Data.Summary.Vehicles[0])
|
||||
}
|
||||
if body.Data.Summary.Vehicles[1].Keyword != "NO_HISTORY_VEHICLE" || body.Data.Summary.Vehicles[1].Status != "no_data" || body.Data.Summary.Vehicles[1].RowCount != 0 {
|
||||
t.Fatalf("empty vehicle should remain visible as a no-data receipt: %+v", body.Data.Summary.Vehicles[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryWindowGrainAndBounds(t *testing.T) {
|
||||
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
|
||||
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
|
||||
}
|
||||
if got := historySeriesGrain(30*24*time.Hour, 240); got != 21600 {
|
||||
t.Fatalf("30d / 240 should use six-hour buckets, got %d", got)
|
||||
}
|
||||
if _, _, _, _, err := historySeriesWindow("2026-06-01", "2026-07-02", time.Now()); err != nil {
|
||||
t.Fatalf("an exact 31-day history window should remain valid: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct{ path, code string }{
|
||||
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
|
||||
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
@@ -403,6 +602,21 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleDetail `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !containsString(body.Data.Sources, "GB32960") || !containsString(body.Data.Sources, "JT808") {
|
||||
t.Fatalf("vehicle detail should expose protocols backed by observed data, got %+v", body.Data.Sources)
|
||||
}
|
||||
if containsString(body.Data.Sources, "YUTONG_MQTT") {
|
||||
t.Fatalf("vehicle detail must not expose an empty canonical slot as an available protocol, got %+v", body.Data.Sources)
|
||||
}
|
||||
if len(body.Data.SourceStatus) < len(canonicalVehicleProtocols) {
|
||||
t.Fatalf("diagnostic source status should retain canonical readiness slots, got %+v", body.Data.SourceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleSourceEvidence(t *testing.T) {
|
||||
@@ -1129,6 +1343,47 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostCarriesLargeArrayQuery(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
handler := NewHandler(NewService(store))
|
||||
vins := make([]string, 250)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
body, err := json.Marshal(MileageQuery{
|
||||
DateFrom: "2026-07-01", DateTo: "2026-07-31", VINs: vins,
|
||||
Protocols: []string{"GB32960", "JT808"}, Deduplicate: true, Limit: 10_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := len(strings.Split(store.lastDailyMileageQuery.Get("vins"), ",")); got != len(vins) {
|
||||
t.Fatalf("daily mileage VIN count = %d, want %d", got, len(vins))
|
||||
}
|
||||
if got := store.lastMileageStatisticsQuery.Get("protocols"); got != "GB32960,JT808" {
|
||||
t.Fatalf("statistics protocols = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostRejectsUnknownJSONField(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/statistics/mileage", bytes.NewBufferString(`{"dateFrom":"2026-07-01","unknown":true}`))
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BAD_JSON") {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
historyExportCleanupAutomationRunLimit = 100
|
||||
historyExportCleanupAutomationAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
allowedHistoryExportCleanupIntervals = map[int]bool{7: true, 14: true, 30: true}
|
||||
allowedHistoryExportCleanupApprovalWindows = map[int]bool{24: true, 48: true, 72: true}
|
||||
)
|
||||
|
||||
type historyExportCleanupAutomationFile struct {
|
||||
Policy HistoryExportCleanupAutomationPolicy `json:"policy"`
|
||||
Runs []HistoryExportCleanupAutomationRun `json:"runs"`
|
||||
}
|
||||
|
||||
type HistoryExportCleanupAutomationTick struct {
|
||||
LockAcquired bool `json:"lockAcquired"`
|
||||
CreatedRunID string `json:"createdRunId,omitempty"`
|
||||
ClaimedRunID string `json:"claimedRunId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func defaultHistoryExportCleanupAutomationPolicy() HistoryExportCleanupAutomationPolicy {
|
||||
return HistoryExportCleanupAutomationPolicy{
|
||||
OlderThanDays: 180,
|
||||
IntervalDays: 7,
|
||||
ApprovalWindowHours: 48,
|
||||
Revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.json")
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationLockPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.lock")
|
||||
}
|
||||
|
||||
func (s *Service) acquireHistoryExportCleanupAutomationLock(nonBlocking bool) (*os.File, error) {
|
||||
if err := os.MkdirAll(s.exportDir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lockFile, err := os.OpenFile(s.historyExportCleanupAutomationLockPath(), os.O_CREATE|os.O_RDWR, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation := syscall.LOCK_EX
|
||||
if nonBlocking {
|
||||
operation |= syscall.LOCK_NB
|
||||
}
|
||||
if err := syscall.Flock(int(lockFile.Fd()), operation); err != nil {
|
||||
_ = lockFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
return lockFile, nil
|
||||
}
|
||||
|
||||
func releaseHistoryExportCleanupAutomationLock(lockFile *os.File) {
|
||||
if lockFile == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
||||
_ = lockFile.Close()
|
||||
}
|
||||
|
||||
func (s *Service) loadHistoryExportCleanupAutomationLocked() (historyExportCleanupAutomationFile, error) {
|
||||
state := historyExportCleanupAutomationFile{Policy: defaultHistoryExportCleanupAutomationPolicy(), Runs: []HistoryExportCleanupAutomationRun{}}
|
||||
contents, err := os.ReadFile(s.historyExportCleanupAutomationPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := json.Unmarshal(contents, &state); err != nil {
|
||||
return historyExportCleanupAutomationFile{}, fmt.Errorf("decode cleanup automation state: %w", err)
|
||||
}
|
||||
if state.Policy.Revision <= 0 {
|
||||
state.Policy = defaultHistoryExportCleanupAutomationPolicy()
|
||||
}
|
||||
if state.Runs == nil {
|
||||
state.Runs = []HistoryExportCleanupAutomationRun{}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistHistoryExportCleanupAutomationLocked(state historyExportCleanupAutomationFile) error {
|
||||
if len(state.Runs) > historyExportCleanupAutomationRunLimit {
|
||||
state.Runs = state.Runs[:historyExportCleanupAutomationRunLimit]
|
||||
}
|
||||
contents, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := s.historyExportCleanupAutomationPath() + ".tmp"
|
||||
if err := os.WriteFile(temporary, contents, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, s.historyExportCleanupAutomationPath())
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationState(state historyExportCleanupAutomationFile, now time.Time) HistoryExportCleanupAutomationState {
|
||||
return HistoryExportCleanupAutomationState{
|
||||
Policy: state.Policy,
|
||||
Runs: append([]HistoryExportCleanupAutomationRun{}, state.Runs...),
|
||||
ServerTime: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HistoryExportCleanupAutomation(ctx context.Context) (HistoryExportCleanupAutomationState, error) {
|
||||
if _, err := historyExportCleanupPrincipal(ctx); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation state: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, time.Now().UTC()), nil
|
||||
}
|
||||
|
||||
func normalizedHistoryExportCleanupAutomationPolicy(request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationPolicyRequest, error) {
|
||||
if !allowedHistoryExportCleanupDays[request.OlderThanDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_WINDOW_INVALID", Message: "自动清理周期仅支持 30、90、180 或 365 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupIntervals[request.IntervalDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_INTERVAL_INVALID", Message: "自动复核频率仅支持每 7、14 或 30 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupApprovalWindows[request.ApprovalWindowHours] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_INVALID", Message: "审批窗口仅支持 24、48 或 72 小时"}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateHistoryExportCleanupAutomation(ctx context.Context, request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
request, err = normalizedHistoryExportCleanupAutomationPolicy(request)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation policy: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
if request.ExpectedRevision != state.Policy.Revision {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REVISION_CONFLICT", Message: "自动清理策略已被其他管理员修改,请刷新后重试"}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
changed := state.Policy.Enabled != request.Enabled ||
|
||||
state.Policy.OlderThanDays != request.OlderThanDays ||
|
||||
state.Policy.IntervalDays != request.IntervalDays ||
|
||||
state.Policy.ApprovalWindowHours != request.ApprovalWindowHours
|
||||
state.Policy.Enabled = request.Enabled
|
||||
state.Policy.OlderThanDays = request.OlderThanDays
|
||||
state.Policy.IntervalDays = request.IntervalDays
|
||||
state.Policy.ApprovalWindowHours = request.ApprovalWindowHours
|
||||
state.Policy.Revision++
|
||||
state.Policy.UpdatedAt = now.Format(time.RFC3339)
|
||||
state.Policy.UpdatedBy = firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
if request.Enabled && changed {
|
||||
state.Policy.NextReviewAt = now.Format(time.RFC3339)
|
||||
}
|
||||
if !request.Enabled {
|
||||
state.Policy.NextReviewAt = ""
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("persist cleanup automation policy: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if request.Enabled {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, "policy-update", 5*time.Minute)
|
||||
}
|
||||
return s.HistoryExportCleanupAutomation(ctx)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunID(now time.Time) string {
|
||||
if identifier, err := randomExportID(); err == nil {
|
||||
return strings.Replace(identifier, "exp_", "cleanup_run_", 1)
|
||||
}
|
||||
return "cleanup_run_" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunFromPreview(policy HistoryExportCleanupAutomationPolicy, preview HistoryExportCleanupPreview, now time.Time) HistoryExportCleanupAutomationRun {
|
||||
status := "awaiting_approval"
|
||||
if preview.PlannedCount == 0 {
|
||||
status = "no_candidates"
|
||||
}
|
||||
run := HistoryExportCleanupAutomationRun{
|
||||
ID: historyExportCleanupAutomationRunID(now), Status: status, Revision: 1, PolicyRevision: policy.Revision,
|
||||
OlderThanDays: preview.OlderThanDays, Cutoff: preview.Cutoff, CandidateCount: preview.CandidateCount,
|
||||
PlannedCount: preview.PlannedCount, ProtectedCount: preview.ProtectedCount, FileCount: preview.FileCount,
|
||||
FileSizeBytes: preview.FileSizeBytes, PreviewToken: preview.PreviewToken, CreatedAt: now.Format(time.RFC3339),
|
||||
ApprovalDeadline: now.Add(time.Duration(policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339),
|
||||
MaxAttempts: historyExportCleanupAutomationAttempts, LastActionAt: now.Format(time.RFC3339), LastActionBy: "scheduler",
|
||||
}
|
||||
if status == "no_candidates" {
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionReason = "本轮没有符合策略且未受保护的归档任务"
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationNextReview(policy HistoryExportCleanupAutomationPolicy, now time.Time) string {
|
||||
next := now.Add(time.Duration(policy.IntervalDays) * 24 * time.Hour)
|
||||
return next.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationDue(value string, now time.Time) bool {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
return err != nil || !parsed.After(now)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRefreshExpired(state *historyExportCleanupAutomationFile, now time.Time) {
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.Status == "awaiting_approval" || run.Status == "needs_review" {
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
run.Status = "expired"
|
||||
run.Revision++
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
run.LastActionReason = "审批窗口已结束,未执行任何清理"
|
||||
}
|
||||
}
|
||||
if run.Status == "running" && historyExportCleanupAutomationDue(run.LeaseExpiresAt, now) {
|
||||
run.Status = "failed"
|
||||
run.Revision++
|
||||
run.Error = "执行实例租约已过期,本轮等待安全恢复"
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
run.NextRetryAt = now.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) automationPreview(now time.Time, olderThanDays int) HistoryExportCleanupPreview {
|
||||
principal := Principal{SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler", Role: "admin", UserType: "admin", AuthProvider: "system"}
|
||||
s.exportsMu.Lock()
|
||||
defer s.exportsMu.Unlock()
|
||||
if s.refreshHistoryExportAvailabilityLocked(now) {
|
||||
_ = s.persistHistoryExportsLocked()
|
||||
}
|
||||
preview, _ := s.historyExportCleanupPreviewLocked(principal, HistoryExportCleanupQuery{OlderThanDays: olderThanDays, OwnerScope: "all"}, now)
|
||||
return preview
|
||||
}
|
||||
|
||||
func (s *Service) claimHistoryExportCleanupAutomationRun(state *historyExportCleanupAutomationFile, workerID string, lease time.Duration, now time.Time) *HistoryExportCleanupAutomationRun {
|
||||
for index := len(state.Runs) - 1; index >= 0; index-- {
|
||||
run := &state.Runs[index]
|
||||
runnable := run.Status == "approved" ||
|
||||
run.Status == "failed" && run.AttemptCount < run.MaxAttempts && historyExportCleanupAutomationDue(run.NextRetryAt, now)
|
||||
if !runnable {
|
||||
continue
|
||||
}
|
||||
run.Status = "running"
|
||||
run.Revision++
|
||||
run.AttemptCount++
|
||||
run.StartedAt = now.Format(time.RFC3339)
|
||||
run.LeaseOwner = workerID + ":" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
run.LeaseExpiresAt = now.Add(lease).Format(time.RFC3339)
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
copy := *run
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RunHistoryExportCleanupAutomationOnce(ctx context.Context, workerID string, lease time.Duration) (HistoryExportCleanupAutomationTick, error) {
|
||||
if strings.TrimSpace(workerID) == "" {
|
||||
workerID = "cleanup-scheduler"
|
||||
}
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(true)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return HistoryExportCleanupAutomationTick{LockAcquired: false}, nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("lock cleanup automation tick: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, err
|
||||
}
|
||||
historyExportCleanupAutomationRefreshExpired(&state, now)
|
||||
tick := HistoryExportCleanupAutomationTick{LockAcquired: true}
|
||||
if state.Policy.Enabled && historyExportCleanupAutomationDue(state.Policy.NextReviewAt, now) {
|
||||
preview := s.automationPreview(now, state.Policy.OlderThanDays)
|
||||
run := historyExportCleanupAutomationRunFromPreview(state.Policy, preview, now)
|
||||
state.Runs = append([]HistoryExportCleanupAutomationRun{run}, state.Runs...)
|
||||
state.Policy.NextReviewAt = historyExportCleanupAutomationNextReview(state.Policy, now)
|
||||
tick.CreatedRunID = run.ID
|
||||
tick.Status = run.Status
|
||||
}
|
||||
claim := s.claimHistoryExportCleanupAutomationRun(&state, workerID, lease, now)
|
||||
if claim != nil {
|
||||
tick.ClaimedRunID = claim.ID
|
||||
tick.Status = claim.Status
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("persist cleanup automation tick: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if claim == nil {
|
||||
return tick, nil
|
||||
}
|
||||
|
||||
schedulerContext := WithPrincipal(ctx, Principal{
|
||||
SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler",
|
||||
Role: "admin", UserType: "admin", AuthProvider: "system",
|
||||
})
|
||||
result, executionErr := s.CleanupHistoryExports(schedulerContext, HistoryExportCleanupRequest{
|
||||
OlderThanDays: claim.OlderThanDays, OwnerScope: "all", PreviewToken: claim.PreviewToken,
|
||||
})
|
||||
if err := s.finishHistoryExportCleanupAutomationRun(claim, result, executionErr); err != nil {
|
||||
return tick, err
|
||||
}
|
||||
return tick, executionErr
|
||||
}
|
||||
|
||||
func (s *Service) finishHistoryExportCleanupAutomationRun(claim *HistoryExportCleanupAutomationRun, result HistoryExportCleanupResult, executionErr error) error {
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock cleanup automation completion: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != claim.ID || run.Status != "running" || run.LeaseOwner != claim.LeaseOwner {
|
||||
continue
|
||||
}
|
||||
run.Revision++
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
if executionErr == nil {
|
||||
run.Status = "completed"
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.CleanupRecordID = result.Record.ID
|
||||
run.CleanedCount = result.Record.Cleaned
|
||||
run.LastActionReason = "审批后的清理批次已完成"
|
||||
} else if clientErr, ok := asClientError(executionErr); ok && clientErr.Code == "EXPORT_CLEANUP_PREVIEW_STALE" {
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Status = "needs_review"
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.ApprovedAt = ""
|
||||
run.ApprovedBy = ""
|
||||
run.ApprovalReason = ""
|
||||
run.AttemptCount = 0
|
||||
run.Error = "候选范围已变化,需要按最新影响重新审批"
|
||||
run.LastActionReason = run.Error
|
||||
} else {
|
||||
run.Status = "failed"
|
||||
run.Error = executionErr.Error()
|
||||
run.LastActionReason = "执行失败,保留审批与候选证据"
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
delay := time.Duration(1<<min(run.AttemptCount-1, 4)) * time.Minute
|
||||
run.NextRetryAt = now.Add(delay).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return s.persistHistoryExportCleanupAutomationLocked(state)
|
||||
}
|
||||
|
||||
func (s *Service) mutateHistoryExportCleanupAutomationRun(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest, action string) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
reason := truncateHistoryPreferenceText(request.Reason, 160)
|
||||
if reason == "" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REASON_REQUIRED", Message: "审批、驳回或恢复执行都需要填写原因"}
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation action: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != strings.TrimSpace(id) {
|
||||
continue
|
||||
}
|
||||
if run.Revision != request.ExpectedRevision {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_CONFLICT", Message: "清理审批已经变化,请刷新后重试"}
|
||||
}
|
||||
switch action {
|
||||
case "approve":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_APPROVABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_EXPIRED", Message: "审批窗口已结束,系统不会执行该批次"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
if preview.PreviewToken != run.PreviewToken {
|
||||
run.Status = "needs_review"
|
||||
run.Revision++
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.Error = "候选范围已变化,请按最新影响再次确认"
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.Error = ""
|
||||
case "reject":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_REJECTABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
run.Status = "rejected"
|
||||
run.RejectedAt = now.Format(time.RFC3339)
|
||||
run.RejectedBy = actor
|
||||
run.RejectionReason = reason
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
case "retry":
|
||||
if run.Status != "failed" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_RETRYABLE", Message: "只有失败批次可以人工恢复执行"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.AttemptCount = 0
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
default:
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_ACTION_INVALID", Message: "不支持的自动清理操作"}
|
||||
}
|
||||
run.Revision++
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_NOT_FOUND", Message: "自动清理批次不存在"}
|
||||
}
|
||||
|
||||
func (s *Service) ApproveHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "approve")
|
||||
}
|
||||
|
||||
func (s *Service) RejectHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "reject")
|
||||
}
|
||||
|
||||
func (s *Service) RetryHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "retry")
|
||||
}
|
||||
|
||||
func (s *Service) StartHistoryExportCleanupAutomation(ctx context.Context) {
|
||||
if !s.runtime.HistoryCleanupAutomation {
|
||||
return
|
||||
}
|
||||
poll := s.runtime.HistoryCleanupPoll
|
||||
if poll < 5*time.Second {
|
||||
poll = time.Minute
|
||||
}
|
||||
if poll > time.Hour {
|
||||
poll = time.Hour
|
||||
}
|
||||
lease := s.runtime.HistoryCleanupLease
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID := firstNonEmpty(strings.TrimSpace(s.runtime.HistoryCleanupWorkerID), "cleanup-scheduler")
|
||||
go func() {
|
||||
ticker := time.NewTicker(poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, workerID, lease)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func addAutomationCleanupCandidate(t *testing.T, service *Service, id string, archivedAt time.Time) {
|
||||
t.Helper()
|
||||
service.exportsMu.Lock()
|
||||
defer service.exportsMu.Unlock()
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: "自动清理候选 " + id, Status: "expired", Format: "csv", Category: "raw",
|
||||
OwnerID: "owner-1", OwnerUsername: "customer-a", CreatedAt: archivedAt.Add(-24 * time.Hour).Format(time.RFC3339),
|
||||
UpdatedAt: archivedAt.Format(time.RFC3339), ArchivedAt: archivedAt.Format(time.RFC3339),
|
||||
RetentionDays: 7, Evidence: "自动清理测试归档",
|
||||
}
|
||||
if err := service.persistHistoryExportsLocked(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRequiresApprovalAndCompletes(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
addAutomationCleanupCandidate(t, service, "automation-old", time.Now().UTC().Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 48, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Policy.Enabled || len(state.Runs) != 1 || state.Runs[0].Status != "awaiting_approval" || state.Runs[0].PlannedCount != 1 {
|
||||
t.Fatalf("saving an enabled policy should schedule a gated review: %+v", state)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "已核对候选、保护例外和文件影响,同意本批执行",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "approved" || state.Runs[0].ApprovedBy != "admin" {
|
||||
t.Fatalf("approval evidence missing: %+v", state.Runs[0])
|
||||
}
|
||||
tick, err := service.RunHistoryExportCleanupAutomationOnce(ctx, "scheduler-a", 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.ClaimedRunID != run.ID {
|
||||
t.Fatalf("approved run was not claimed: %+v", tick)
|
||||
}
|
||||
state, err = service.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "completed" || state.Runs[0].CleanedCount != 1 || state.Runs[0].CleanupRecordID == "" || state.Runs[0].AttemptCount != 1 {
|
||||
t.Fatalf("completed run should retain execution evidence: %+v", state.Runs[0])
|
||||
}
|
||||
if _, exists := service.historyExportJob("automation-old"); exists {
|
||||
t.Fatal("approved automation run should remove the planned archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRefreshesChangedScopeBeforeApproval(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
now := time.Now().UTC()
|
||||
addAutomationCleanupCandidate(t, service, "automation-first", now.Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 24, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
addAutomationCleanupCandidate(t, service, "automation-late", now.Add(-190*24*time.Hour))
|
||||
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "审批前复核自动清理影响",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed := state.Runs[0]
|
||||
if refreshed.Status != "needs_review" || refreshed.PlannedCount != 2 || refreshed.PreviewToken == run.PreviewToken || refreshed.ApprovedAt != "" {
|
||||
t.Fatalf("changed candidates must force a fresh approval: before=%+v after=%+v", run, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationUsesCrossProcessFileLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
lockFile, err := first.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
tick, err := second.RunHistoryExportCleanupAutomationOnce(exportAdminContext(), "scheduler-b", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.LockAcquired {
|
||||
t.Fatalf("second scheduler instance must not enter a locked tick: %+v", tick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationStateSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
addAutomationCleanupCandidate(t, first, "automation-persisted", time.Now().UTC().Add(-365*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := first.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 365, IntervalDays: 14, ApprovalWindowHours: 72, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "cleanup-automation.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloaded, err := second.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Policy.Revision != state.Policy.Revision || len(reloaded.Runs) != 1 || reloaded.Runs[0].ID != state.Runs[0].ID {
|
||||
t.Fatalf("automation state did not survive restart: first=%+v reloaded=%+v", state, reloaded)
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,29 @@ import (
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
accessIdentities []AccessUnresolvedIdentity
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
businessRelations map[string]VehicleBusinessRelation
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertRuleRevisions map[string][]AlertRuleRevision
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
alertNotificationRetryAudits []AlertNotificationRetryAudit
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
nextNotificationRetryAuditID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
}
|
||||
|
||||
func NewMockStore() *MockStore {
|
||||
@@ -40,14 +45,47 @@ func NewMockStore() *MockStore {
|
||||
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
|
||||
}
|
||||
store := &MockStore{
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
accessIdentities: []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}},
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
businessRelations: map[string]VehicleBusinessRelation{
|
||||
"LB9A32A24R0LS1426": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10001",
|
||||
VIN: "LB9A32A24R0LS1426", PlateNumber: "粤AG18312",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", ContractID: "30001", ContractCode: "HT-2026-001",
|
||||
ProjectName: "氢能物流示范项目", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "运营中",
|
||||
ScopeStartAt: "2026-03-01 08:00:00", SourceUpdatedAt: "2026-07-24 08:00:00", PublishedAt: "2026-07-24 08:02:00",
|
||||
},
|
||||
"LNXNEGRR7SR318212": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10002",
|
||||
VIN: "LNXNEGRR7SR318212", PlateNumber: "川AHTWO1",
|
||||
CustomerID: "20002", CustomerName: "成都氢运客户", DepartmentID: "40002", DepartmentName: "业务二部",
|
||||
ResponsibleUserID: "50002", ResponsibleUserName: "李业务", OperationStatus: "运营中",
|
||||
},
|
||||
"LMRKH9AC2R1004087": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10003",
|
||||
VIN: "LMRKH9AC2R1004087", PlateNumber: "豫A88888",
|
||||
CustomerID: "20003", CustomerName: "中原物流客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50003", ResponsibleUserName: "王业务", OperationStatus: "待交付",
|
||||
},
|
||||
"LB9A32A24P0LS1230": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10004",
|
||||
VIN: "LB9A32A24P0LS1230", PlateNumber: "粤AFF7936",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "已停运",
|
||||
},
|
||||
},
|
||||
locations: []RealtimeLocationRow{
|
||||
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
|
||||
{VIN: vehicles[1].VIN, Plate: vehicles[1].Plate, Protocol: vehicles[1].Protocol, Longitude: 104.0668, Latitude: 30.5728, SpeedKmh: 18.3, SOCPercent: 64.8, TotalMileageKm: 119925, LastSeen: vehicles[1].LastSeen},
|
||||
@@ -106,6 +144,11 @@ func (m *MockStore) ReconciliationSummary(_ context.Context, _ int) (Reconciliat
|
||||
rules := map[string]int{}
|
||||
severities := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt != "" {
|
||||
result.Archived++
|
||||
continue
|
||||
}
|
||||
result.Current++
|
||||
switch item.Status {
|
||||
case "pending":
|
||||
result.Active++
|
||||
@@ -137,7 +180,13 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
items := make([]ReconciliationIssue, 0, len(m.reconciliationIssues))
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary), strings.ToLower(query.Keyword)) {
|
||||
if query.Scope == "archived" && item.ArchivedAt == "" {
|
||||
continue
|
||||
}
|
||||
if query.Scope != "archived" && item.ArchivedAt != "" {
|
||||
continue
|
||||
}
|
||||
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary+" "+item.ArchivedBy+" "+item.ArchiveReason), strings.ToLower(query.Keyword)) {
|
||||
continue
|
||||
}
|
||||
if query.RuleCode != "" && query.RuleCode != "all" && item.RuleCode != query.RuleCode {
|
||||
@@ -155,6 +204,27 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
if query.Status != "" && query.Status != "all" && query.Status != "active" && item.Status != query.Status {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "unassigned" && item.Assignee != "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "assigned" && item.Assignee == "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner != "" && query.Owner != "all" && query.Owner != "unassigned" && query.Owner != "assigned" && item.Assignee != query.Owner {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "overdue" && (item.DueAt == "" || item.DueAt >= time.Now().Format("2006-01-02 15:04:05")) {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "due_soon" {
|
||||
if item.DueAt == "" {
|
||||
continue
|
||||
}
|
||||
due, _ := time.Parse("2006-01-02 15:04:05", item.DueAt)
|
||||
if due.Before(time.Now()) || due.After(time.Now().Add(8*time.Hour)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cloned := cloneReconciliationIssue(item)
|
||||
cloned.Actions = nil
|
||||
items = append(items, cloned)
|
||||
@@ -169,6 +239,28 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationAssignees(_ context.Context) ([]ReconciliationAssignee, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
counts := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt == "" && item.Assignee != "" && (item.Status == "pending" || strings.HasPrefix(item.Status, "confirmed_source_")) {
|
||||
counts[item.Assignee]++
|
||||
}
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(counts))
|
||||
for name, count := range counts {
|
||||
items = append(items, ReconciliationAssignee{Name: name, Source: "history", ActiveCount: count})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationIssue(_ context.Context, id string) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
@@ -191,6 +283,9 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
from := item.Status
|
||||
item.Status = request.Status
|
||||
item.ResolutionNote = request.Note
|
||||
@@ -210,6 +305,80 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AssignReconciliationIssue(_ context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
due, err := time.Parse(time.RFC3339, request.DueAt)
|
||||
if strings.TrimSpace(request.Assignee) == "" || err != nil {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_INVALID", Message: "负责人或处理期限无效"}
|
||||
}
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if item.Status != "pending" && !strings.HasPrefix(item.Status, "confirmed_source_") {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
item.Assignee = strings.TrimSpace(request.Assignee)
|
||||
item.AssignedBy = request.Actor
|
||||
item.AssignedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.DueAt = due.UTC().Format("2006-01-02 15:04:05")
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{ID: int64(len(item.Actions) + 1), Action: "assign", Actor: request.Actor, Note: "交接给 " + item.Assignee, CreatedAt: item.AssignedAt})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetReconciliationIssueArchived(_ context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
currentlyArchived := item.ArchivedAt != ""
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if item.Status != "fixed" && item.Status != "no_action" && item.Status != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
item.ArchivedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.ArchivedBy = request.Actor
|
||||
item.ArchiveReason = request.Reason
|
||||
} else {
|
||||
item.ArchivedAt = ""
|
||||
item.ArchivedBy = ""
|
||||
item.ArchiveReason = ""
|
||||
}
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{
|
||||
ID: int64(len(item.Actions) + 1), Action: action, FromStatus: item.Status, ToStatus: item.Status,
|
||||
Actor: request.Actor, Note: request.Reason, CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateReconciliation(_ context.Context) (ReconciliationEvaluationResult, error) {
|
||||
summary, _ := m.ReconciliationSummary(context.Background(), 30)
|
||||
return ReconciliationEvaluationResult{
|
||||
@@ -298,6 +467,11 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil
|
||||
return profile, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessRelation(_ context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
relation, ok := m.businessRelations[vin]
|
||||
return relation, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
if vin != "LB9A32A24R0LS1426" {
|
||||
return VehicleSourceEvidence{
|
||||
@@ -420,15 +594,81 @@ func (m *MockStore) AccessEvidence(context.Context) ([]AccessEvidenceRow, error)
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessUnresolvedIdentities(_ context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
|
||||
items := []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}}
|
||||
if query.Offset >= len(items) {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
m.accessMu.RLock()
|
||||
defer m.accessMu.RUnlock()
|
||||
items := make([]AccessUnresolvedIdentity, 0, len(m.accessIdentities))
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, item := range m.accessIdentities {
|
||||
if query.Protocol != "" && !strings.EqualFold(item.Protocol, query.Protocol) {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(item.IdentifierMasked+" "+item.Plate+" "+item.Manufacturer+" "+item.SourceEndpoint), keyword) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
total := len(items)
|
||||
if query.Offset >= total {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
end := query.Offset + query.Limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: append([]AccessUnresolvedIdentity(nil), items[query.Offset:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ClaimAccessIdentity(_ context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
m.accessMu.Lock()
|
||||
identityIndex := -1
|
||||
var identity AccessUnresolvedIdentity
|
||||
for index, item := range m.accessIdentities {
|
||||
if item.ID == identityID {
|
||||
identityIndex = index
|
||||
identity = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if identityIndex < 0 {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
plate := ""
|
||||
vehicleFound := false
|
||||
for _, vehicle := range m.vehicles {
|
||||
if strings.EqualFold(vehicle.VIN, input.VIN) {
|
||||
vehicleFound = true
|
||||
if plate == "" {
|
||||
plate = vehicle.Plate
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vehicleFound {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
m.accessIdentities = append(m.accessIdentities[:identityIndex], m.accessIdentities[identityIndex+1:]...)
|
||||
m.accessMu.Unlock()
|
||||
|
||||
m.profileMu.RLock()
|
||||
profile, hasProfile := m.profiles[input.VIN]
|
||||
m.profileMu.RUnlock()
|
||||
missing := []string{}
|
||||
checks := []struct{ label, value string }{
|
||||
{"车辆品牌", profile.BrandName}, {"车型", profile.ModelName}, {"车辆类型", profile.VehicleType}, {"所属企业", profile.CompanyName},
|
||||
{"运营状态", profile.OperationStatus}, {"接入服务商", profile.AccessProvider}, {"首次接入", profile.FirstAccessAt},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !hasProfile || strings.TrimSpace(check.value) == "" || (check.label == "运营状态" && strings.EqualFold(check.value, "unknown")) {
|
||||
missing = append(missing, check.label)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identity.ID, Protocol: identity.Protocol, IdentifierMasked: identity.IdentifierMasked,
|
||||
VIN: input.VIN, Plate: plate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: now.Format(time.RFC3339), AuditID: now.UnixNano(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessThresholds(context.Context) (AccessThresholdConfig, error) {
|
||||
@@ -575,7 +815,8 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen)
|
||||
row.ServiceStatus = buildVehicleCoverageServiceStatus(*row)
|
||||
row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row)
|
||||
if keepCoverageRow(*row, query) {
|
||||
relation, hasRelation := m.businessRelations[row.VIN]
|
||||
if matchesBusinessFilters(relation, hasRelation, query) && keepCoverageRow(*row, query) {
|
||||
items = append(items, *row)
|
||||
}
|
||||
}
|
||||
@@ -583,6 +824,75 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func matchesBusinessFilters(relation VehicleBusinessRelation, found bool, query url.Values) bool {
|
||||
filters := []struct {
|
||||
raw string
|
||||
value string
|
||||
}{
|
||||
{query.Get("departmentIds"), relation.DepartmentID},
|
||||
{query.Get("responsibleUserIds"), relation.ResponsibleUserID},
|
||||
{query.Get("customerIds"), relation.CustomerID},
|
||||
{query.Get("operationStatuses"), relation.OperationStatus},
|
||||
}
|
||||
for _, filter := range filters {
|
||||
values := splitCSV(filter.raw)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if !found || !containsString(values, filter.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessFilters(_ context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
scope := map[string]bool{}
|
||||
if values := splitCSV(query.Get("scopeVins")); len(values) > 0 {
|
||||
for _, vin := range values {
|
||||
scope[vin] = true
|
||||
}
|
||||
}
|
||||
type counter struct {
|
||||
label string
|
||||
vins map[string]bool
|
||||
}
|
||||
departments := map[string]*counter{}
|
||||
responsibleUsers := map[string]*counter{}
|
||||
customers := map[string]*counter{}
|
||||
statuses := map[string]*counter{}
|
||||
add := func(target map[string]*counter, value, label, vin string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
if target[value] == nil {
|
||||
target[value] = &counter{label: firstNonEmpty(label, value), vins: map[string]bool{}}
|
||||
}
|
||||
target[value].vins[vin] = true
|
||||
}
|
||||
for vin, relation := range m.businessRelations {
|
||||
if len(scope) > 0 && !scope[vin] {
|
||||
continue
|
||||
}
|
||||
add(departments, relation.DepartmentID, relation.DepartmentName, vin)
|
||||
add(responsibleUsers, relation.ResponsibleUserID, relation.ResponsibleUserName, vin)
|
||||
add(customers, relation.CustomerID, relation.CustomerName, vin)
|
||||
add(statuses, relation.OperationStatus, relation.OperationStatus, vin)
|
||||
}
|
||||
options := func(source map[string]*counter) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for value, item := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: value, Label: item.label, Count: len(item.vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Label < result[j].Label })
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: options(departments), ResponsibleUsers: options(responsibleUsers),
|
||||
Customers: options(customers), Statuses: options(statuses),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
allQuery := copyValues(query)
|
||||
allQuery.Set("limit", "100000")
|
||||
@@ -898,12 +1208,23 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items = append(items, *row)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := strings.ToUpper(firstNonEmpty(items[i].Plate, items[i].VIN))
|
||||
right := strings.ToUpper(firstNonEmpty(items[j].Plate, items[j].VIN))
|
||||
if left == right {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
} else {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
}
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
@@ -1347,20 +1668,37 @@ func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
||||
}
|
||||
|
||||
func filterVehicles(rows []VehicleRow, query url.Values) []VehicleRow {
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Get("keyword")))
|
||||
keywords := vehicleSearchKeywords(query)
|
||||
batch := strings.TrimSpace(query.Get("keywords")) != ""
|
||||
protocol := strings.TrimSpace(query.Get("protocol"))
|
||||
if keyword == "" && protocol == "" {
|
||||
scopeRaw := strings.TrimSpace(query.Get("scopeVins"))
|
||||
scope := map[string]bool{}
|
||||
for _, vin := range splitCSV(scopeRaw) {
|
||||
scope[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if len(keywords) == 0 && protocol == "" && scopeRaw == "" {
|
||||
return rows
|
||||
}
|
||||
return keep(rows, func(row VehicleRow) bool {
|
||||
if scopeRaw != "" && !scope[strings.ToUpper(strings.TrimSpace(row.VIN))] {
|
||||
return false
|
||||
}
|
||||
if protocol != "" && row.Protocol != protocol {
|
||||
return false
|
||||
}
|
||||
if keyword == "" {
|
||||
if len(keywords) == 0 {
|
||||
return true
|
||||
}
|
||||
if batch {
|
||||
for _, keyword := range keywords {
|
||||
if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(row.Phone, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
value := strings.ToLower(row.VIN + row.Plate + row.Phone + row.OEM)
|
||||
return strings.Contains(value, keyword)
|
||||
return strings.Contains(value, strings.ToLower(keywords[0]))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,10 +76,22 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
if strings.TrimSpace(query.Get("keywords")) != "" {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
|
||||
where = append(where, "(v.vin IN ("+placeholders+") OR s.plate IN ("+placeholders+") OR b.vin IN ("+placeholders+") OR b.plate IN ("+placeholders+") OR b.phone IN ("+placeholders+"))")
|
||||
for range 5 {
|
||||
for _, keyword := range keywords {
|
||||
args = append(args, keyword)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
keyword := keywords[0]
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "s.protocol = ?")
|
||||
@@ -148,6 +160,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
|
||||
havingSQL
|
||||
@@ -175,6 +189,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
@@ -257,6 +272,8 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
`FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin ` + havingSQL
|
||||
return SQLQuery{
|
||||
@@ -275,6 +292,14 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func appendBusinessCoverageFilters(where []string, args []any, query url.Values) ([]string, []any) {
|
||||
where, args = appendCSVListFilter(where, args, "bs.department_id", query.Get("departmentIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.responsible_user_id", query.Get("responsibleUserIds"))
|
||||
where, args = appendCSVListFilter(where, args, "CAST(bs.customer_id AS CHAR)", query.Get("customerIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.operation_status", query.Get("operationStatuses"))
|
||||
return where, args
|
||||
}
|
||||
|
||||
func buildVehicleCoverageSetSQL(scope string) (string, []any) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
@@ -454,6 +479,10 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
// Ordering only by updated_at makes the selected point flap whenever protocols
|
||||
// report at different cadences. When every source is stale, recency remains the
|
||||
// safest fallback so an old high-priority source cannot mask newer evidence.
|
||||
vehicleOrderSQL := `MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC`
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC`
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
||||
@@ -482,7 +511,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
|
||||
groupSQL +
|
||||
`ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
|
||||
`ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
|
||||
CountArgs: countArgs,
|
||||
@@ -522,36 +551,57 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
fromSQL := `FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK'
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, m.protocol ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
|
||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
@@ -585,7 +635,8 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
return SQLQuery{
|
||||
Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` +
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km ` + fromSQL,
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km, ` +
|
||||
`COALESCE(SUM(m.pure_hydrogen_mileage_km), 0) AS total_pure_hydrogen_mileage_km ` + fromSQL,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
@@ -625,28 +676,43 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder := mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK' ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
|
||||
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||
}
|
||||
|
||||
@@ -740,23 +806,31 @@ func mileageDailySelectionOrder(mileageColumn, protocolColumn string, protocols
|
||||
}
|
||||
|
||||
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 0)
|
||||
}
|
||||
|
||||
func appendCSVListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 200)
|
||||
}
|
||||
|
||||
func appendListFilter(where []string, args []any, column string, raw string, maxValues int) ([]string, []any) {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
value := strings.TrimSpace(item)
|
||||
if value == "" || seen[value] || (maxValues > 0 && len(values) >= maxValues) {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
values = append(values, vin)
|
||||
seen[value] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return where, args
|
||||
}
|
||||
placeholders := make([]string, len(values))
|
||||
for index, vin := range values {
|
||||
for index, value := range values {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
args = append(args, value)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
@@ -13,25 +13,29 @@ type VehicleGrant struct {
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleGrants []VehicleGrant `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleGrants []VehicleGrant `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
BusinessScopeLevel string `json:"businessScopeLevel,omitempty"`
|
||||
DepartmentIDs []string `json:"departmentIds,omitempty"`
|
||||
ResponsibleUserID string `json:"responsibleUserId,omitempty"`
|
||||
}
|
||||
|
||||
func (p Principal) Clone() Principal {
|
||||
p.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...)
|
||||
p.DepartmentIDs = append([]string(nil), p.DepartmentIDs...)
|
||||
p.VehicleCount = len(p.VehicleVINs)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ProductionStore struct {
|
||||
accessSchema schemaReadyGate
|
||||
alertSchema schemaReadyGate
|
||||
profileSchema schemaReadyGate
|
||||
businessScopeSchema schemaReadyGate
|
||||
reconciliationSchema schemaReadyGate
|
||||
}
|
||||
|
||||
@@ -628,10 +629,11 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
|
||||
}
|
||||
limit, offset := buildLimitOffset(query)
|
||||
tdQuery := map[string]string{
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"skipCount": query.Get("skipCount"),
|
||||
}
|
||||
if value := strings.TrimSpace(query.Get("dateFrom")); value != "" {
|
||||
tdQuery["dateFrom"] = value
|
||||
@@ -1027,7 +1029,7 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
|
||||
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
||||
built := buildMileageSummarySQL(query)
|
||||
var summary MileageSummary
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm, &summary.TotalPureHydrogenMileageKm); err != nil {
|
||||
return MileageSummary{}, err
|
||||
}
|
||||
if summary.VehicleCount > 0 {
|
||||
@@ -1052,9 +1054,15 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
items := make([]DailyMileageRow, 0)
|
||||
for rows.Next() {
|
||||
var row DailyMileageRow
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.Source); err != nil {
|
||||
var hydrogen sql.NullFloat64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if hydrogen.Valid {
|
||||
consumption := hydrogen.Float64
|
||||
row.HydrogenConsumptionKg = &consumption
|
||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1071,12 +1079,26 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
result := MileageStatistics{
|
||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重)/ vehicle_realtime_location(最新里程表)",
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的总里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||
}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||
&result.VehicleCount,
|
||||
&result.RecordCount,
|
||||
&result.PeriodMileageKm,
|
||||
&result.PeriodPureHydrogenMileageKm,
|
||||
&result.HydrogenDataDays,
|
||||
&result.PeriodHydrogenConsumptionKg,
|
||||
&result.HydrogenMatchedMileageKm,
|
||||
&result.AverageDailyMileageKm,
|
||||
); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
result.PeriodHydrogenConsumptionKg,
|
||||
result.HydrogenMatchedMileageKm,
|
||||
result.HydrogenDataDays,
|
||||
)
|
||||
if result.VehicleCount > 0 {
|
||||
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
|
||||
}
|
||||
@@ -1095,10 +1117,27 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
}
|
||||
for rows.Next() {
|
||||
var point MileageTrendPoint
|
||||
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
|
||||
var hydrogenConsumptionKg float64
|
||||
if err := rows.Scan(
|
||||
&point.Date,
|
||||
&point.MileageKm,
|
||||
&point.PureHydrogenMileageKm,
|
||||
&point.HydrogenDataDays,
|
||||
&hydrogenConsumptionKg,
|
||||
&point.HydrogenMatchedMileageKm,
|
||||
&point.Vehicles,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if point.HydrogenDataDays > 0 {
|
||||
point.HydrogenConsumptionKg = &hydrogenConsumptionKg
|
||||
point.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
hydrogenConsumptionKg,
|
||||
point.HydrogenMatchedMileageKm,
|
||||
point.HydrogenDataDays,
|
||||
)
|
||||
}
|
||||
result.Trend = append(result.Trend, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1128,6 +1167,14 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hydrogenRatePer100Km(consumptionKg, dailyMileageKm float64, dataDays int) *float64 {
|
||||
if dataDays <= 0 || consumptionKg < 0 || dailyMileageKm <= 0 {
|
||||
return nil
|
||||
}
|
||||
rate := consumptionKg * 100 / dailyMileageKm
|
||||
return &rate
|
||||
}
|
||||
|
||||
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
|
||||
@@ -141,6 +141,29 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
consumption float64
|
||||
mileage float64
|
||||
dataDays int
|
||||
}{
|
||||
{name: "no quality-approved days", consumption: 7.3, mileage: 128.4},
|
||||
{name: "no matched daily mileage", consumption: 7.3, dataDays: 2},
|
||||
{name: "negative consumption", consumption: -1, mileage: 128.4, dataDays: 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hydrogenRatePer100Km(test.consumption, test.mileage, test.dataDays); got != nil {
|
||||
t.Fatalf("hydrogen rate = %v, want nil", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTDengineTableNotExistErrorIsRecognized(t *testing.T) {
|
||||
err := errors.New("[0x2603] Fail to get table info, error: Table does not exist")
|
||||
if !isTDengineTableNotExist(err) {
|
||||
|
||||
@@ -28,6 +28,35 @@ func TestBuildVehicleListSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLAppliesBusinessMultiSelects(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"},
|
||||
"customerIds": {"20001,20002"}, "operationStatuses": {"运营中,待交付"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"business_scope_state", "business_customer_vehicle_scope",
|
||||
"bs.department_id IN (?,?)", "bs.responsible_user_id IN (?,?)",
|
||||
"CAST(bs.customer_id AS CHAR) IN (?,?)", "bs.operation_status IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("business filter SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 10 || len(built.CountArgs) != 8 {
|
||||
t.Fatalf("unexpected business filter args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReconciliationWhereSupportsExactOwner(t *testing.T) {
|
||||
where, args := buildReconciliationWhere(ReconciliationQuery{Status: "active", Owner: "定位运维组"})
|
||||
if !strings.Contains(where, "i.assignee=?") || !strings.Contains(where, "i.status IN") {
|
||||
t.Fatalf("exact owner filter missing from where clause: %s", where)
|
||||
}
|
||||
if len(args) != 1 || args[0] != "定位运维组" {
|
||||
t.Fatalf("unexpected owner args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleListSQL(query)
|
||||
@@ -67,6 +96,30 @@ func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLSupportsExactBatchIdentitySearch(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"keywords": {"粤AG18312, LMRKH9AC2R1004087, 粤AG18312"},
|
||||
"limit": {"100"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"v.vin IN (?,?)",
|
||||
"s.plate IN (?,?)",
|
||||
"b.vin IN (?,?)",
|
||||
"b.plate IN (?,?)",
|
||||
"b.phone IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("batch coverage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 12 || len(built.CountArgs) != 10 {
|
||||
t.Fatalf("unexpected batch coverage args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
if built.Args[0] != "粤AG18312" || built.Args[1] != "LMRKH9AC2R1004087" || built.Args[10] != 100 {
|
||||
t.Fatalf("batch coverage args should preserve deduplicated identities: %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
@@ -293,6 +346,16 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLSupportsStableIdentityOrder(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"sort": {"identity"}, "limit": {"20"}})
|
||||
if !strings.Contains(built.Text, "ORDER BY COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("identity sort should keep mobile list membership and order stable: %s", built.Text)
|
||||
}
|
||||
if strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) IS NULL ASC") {
|
||||
t.Fatalf("identity sort should not reorder the list on every realtime report: %s", built.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
|
||||
for _, text := range []string{built.Text, built.CountText} {
|
||||
@@ -328,6 +391,30 @@ func TestBuildVehicleRealtimeSQLBoundsCopiedPlateSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLPreservesLargeAuthorizedVINScope(t *testing.T) {
|
||||
const authorizedVehicleCount = 1024
|
||||
vins := make([]string, authorizedVehicleCount)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
built := buildVehicleRealtimeSQL(url.Values{
|
||||
"scopeVins": {strings.Join(vins, ",")},
|
||||
"limit": {"10000"},
|
||||
})
|
||||
if len(built.CountArgs) != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN count args = %d, want %d", len(built.CountArgs), authorizedVehicleCount)
|
||||
}
|
||||
if len(built.Args) != authorizedVehicleCount+2 {
|
||||
t.Fatalf("authorized VIN query args = %d, want %d", len(built.Args), authorizedVehicleCount+2)
|
||||
}
|
||||
if built.CountArgs[0] != vins[0] || built.CountArgs[authorizedVehicleCount-1] != vins[authorizedVehicleCount-1] {
|
||||
t.Fatalf("authorized VIN scope was truncated: first=%v last=%v", built.CountArgs[0], built.CountArgs[authorizedVehicleCount-1])
|
||||
}
|
||||
if strings.Count(built.CountText, "?") != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN placeholders = %d, want %d", strings.Count(built.CountText, "?"), authorizedVehicleCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
@@ -395,6 +482,14 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
if strings.Contains(built.Text, "m.first_total_mileage_km") || !strings.Contains(built.Text, "m.latest_total_mileage_km - m.daily_mileage_km") {
|
||||
t.Fatalf("daily mileage must follow the current projection schema and derive its start value: %s", built.Text)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
|
||||
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text)
|
||||
}
|
||||
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
|
||||
t.Fatalf("count SQL = %s", built.CountText)
|
||||
}
|
||||
@@ -473,7 +568,7 @@ func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *te
|
||||
|
||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))", "MAX(h.consumption_kg)", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
@@ -578,7 +673,7 @@ func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildMileageSummarySQL(query)
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "SUM(m.pure_hydrogen_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
@@ -594,7 +689,18 @@ func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
|
||||
for _, want := range []string{
|
||||
"GROUP BY m.vin, m.stat_date",
|
||||
"MAX(COALESCE(m.daily_mileage_km",
|
||||
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
||||
"MAX(h.consumption_kg)",
|
||||
"COUNT(DISTINCT d.vin)",
|
||||
"SUM(d.daily_mileage_km)",
|
||||
"SUM(d.pure_hydrogen_mileage_km)",
|
||||
"COUNT(d.hydrogen_consumption_kg)",
|
||||
"SUM(d.hydrogen_consumption_kg)",
|
||||
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
|
||||
} {
|
||||
if !strings.Contains(summary.Text, want) {
|
||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||
}
|
||||
@@ -603,7 +709,8 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
t.Fatalf("statistics args = %#v", summary.Args)
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
||||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
|
||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
@@ -646,12 +753,12 @@ func TestNormalizeMileageVINSelection(t *testing.T) {
|
||||
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {
|
||||
t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err)
|
||||
}
|
||||
tooMany := make([]string, 21)
|
||||
tooMany := make([]string, 50_001)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("VIN%03d", index)
|
||||
}
|
||||
if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil {
|
||||
t.Fatal("more than 20 selected vehicles should be rejected")
|
||||
t.Fatal("more than 50,000 selected vehicles should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +798,23 @@ func TestBuildRawFrameSQLCanSkipUnneededCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQLCanSkipUnneededCount(t *testing.T) {
|
||||
for _, deduplicate := range []string{"", "1"} {
|
||||
built := buildDailyMileageSQL(url.Values{
|
||||
"vins": {"VIN001"},
|
||||
"limit": {"20"},
|
||||
"skipCount": {"1"},
|
||||
"deduplicate": {deduplicate},
|
||||
})
|
||||
if built.CountText != "" || len(built.CountArgs) != 0 {
|
||||
t.Fatalf("bounded mileage preview should skip count for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.vin IN (?)") || !strings.Contains(built.Text, "LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("bounded mileage preview should keep exact VIN pagination for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"protocol": "JT808",
|
||||
@@ -717,6 +841,15 @@ func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLCanSkipUnneededCount(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "limit": "20", "skipCount": "1",
|
||||
})
|
||||
if built.CountText != "" || !strings.Contains(built.Text, "LIMIT 20") {
|
||||
t.Fatalf("bounded latest query should skip count: %+v", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLNormalizesDatetimeLocalMinutePrecision(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "dateFrom": "2026-07-14T00:00", "dateTo": "2026-07-14T05:56", "limit": "10",
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReconciliationExportFile struct {
|
||||
Name string
|
||||
Content []byte
|
||||
RowCount int
|
||||
}
|
||||
|
||||
func (s *Service) reconciliationStore() (ReconciliationStore, error) {
|
||||
store, ok := s.store.(ReconciliationStore)
|
||||
if !ok {
|
||||
@@ -31,10 +41,16 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
query.Scope = strings.ToLower(strings.TrimSpace(query.Scope))
|
||||
if query.Scope != "archived" {
|
||||
query.Scope = "current"
|
||||
}
|
||||
query.RuleCode = strings.TrimSpace(query.RuleCode)
|
||||
query.Category = strings.TrimSpace(query.Category)
|
||||
query.Severity = strings.TrimSpace(query.Severity)
|
||||
query.Status = strings.TrimSpace(query.Status)
|
||||
query.Owner = strings.TrimSpace(query.Owner)
|
||||
query.SLA = strings.TrimSpace(query.SLA)
|
||||
if query.Limit <= 0 || query.Limit > 200 {
|
||||
query.Limit = 50
|
||||
}
|
||||
@@ -44,6 +60,256 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return store.ReconciliationIssues(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationAssignees(ctx context.Context, search string) ([]ReconciliationAssignee, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
search = strings.TrimSpace(search)
|
||||
if len([]rune(search)) > 80 {
|
||||
return nil, clientError{Code: "RECONCILIATION_ASSIGNEE_SEARCH_TOO_LONG", Message: "负责人搜索不能超过 80 个字符"}
|
||||
}
|
||||
items, err := store.ReconciliationAssignees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]ReconciliationAssignee, len(items)+1)
|
||||
for _, item := range items {
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok {
|
||||
name := strings.TrimSpace(principal.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
if name != "" {
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "current"}
|
||||
}
|
||||
if item.Username == "" {
|
||||
item.Username = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
item.Current = true
|
||||
byName[key] = item
|
||||
}
|
||||
}
|
||||
needle := strings.ToLower(search)
|
||||
result := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
if needle != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Username), needle) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Current != result[j].Current {
|
||||
return result[i].Current
|
||||
}
|
||||
if result[i].ActiveCount != result[j].ActiveCount {
|
||||
return result[i].ActiveCount > result[j].ActiveCount
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportReconciliationIssues(ctx context.Context, query ReconciliationQuery) (ReconciliationExportFile, error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 200
|
||||
first, err := s.ReconciliationIssues(ctx, query)
|
||||
if err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
if first.Total > reconciliationFindingLimit {
|
||||
return ReconciliationExportFile{}, clientError{Code: "RECONCILIATION_EXPORT_TOO_LARGE", Message: "当前筛选超过 50,000 条,请缩小范围后导出"}
|
||||
}
|
||||
items := append([]ReconciliationIssue(nil), first.Items...)
|
||||
for offset := len(first.Items); offset < first.Total; offset += query.Limit {
|
||||
query.Offset = offset
|
||||
page, pageErr := s.ReconciliationIssues(ctx, query)
|
||||
if pageErr != nil {
|
||||
return ReconciliationExportFile{}, pageErr
|
||||
}
|
||||
items = append(items, page.Items...)
|
||||
if len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"差异编号", "等级", "状态", "规则", "问题", "VIN", "车牌", "来源 A", "来源 B", "负责人", "处理期限", "首次发现", "最近发现", "命中次数", "结论说明", "处置人", "归档时间", "归档人", "归档原因", "版本"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{
|
||||
reconciliationCSVCell(item.ID), reconciliationCSVCell(reconciliationSeverityLabel(item.Severity)), reconciliationCSVCell(reconciliationStatusLabel(item.Status)),
|
||||
reconciliationCSVCell(item.RuleCode), reconciliationCSVCell(item.Title), reconciliationCSVCell(item.VIN), reconciliationCSVCell(item.Plate),
|
||||
reconciliationCSVCell(item.ProtocolA), reconciliationCSVCell(item.ProtocolB), reconciliationCSVCell(item.Assignee), reconciliationCSVCell(item.DueAt),
|
||||
reconciliationCSVCell(item.FirstSeenAt), reconciliationCSVCell(item.LastSeenAt), fmt.Sprintf("%d", item.OccurrenceCount),
|
||||
reconciliationCSVCell(item.ResolutionNote), reconciliationCSVCell(item.ResolvedBy),
|
||||
reconciliationCSVCell(item.ArchivedAt), reconciliationCSVCell(item.ArchivedBy), reconciliationCSVCell(item.ArchiveReason),
|
||||
fmt.Sprintf("%d", item.Version),
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
return ReconciliationExportFile{
|
||||
Name: fmt.Sprintf("质量差异_%s.csv", time.Now().Format("20060102_150405")),
|
||||
Content: buffer.Bytes(),
|
||||
RowCount: len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func reconciliationCSVCell(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && strings.ContainsRune("=+-@", rune(value[0])) {
|
||||
return "'" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationSeverityLabel(value string) string {
|
||||
if label := map[string]string{"critical": "严重", "major": "重要", "minor": "一般"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationStatusLabel(value string) string {
|
||||
if label := map[string]string{"pending": "待处理", "confirmed_source_a": "确认来源 A", "confirmed_source_b": "确认来源 B", "no_action": "无需处理", "fixed": "已修复", "recovered": "已恢复"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeReconciliationLifecycleRequest(ctx context.Context, request ReconciliationLifecycleRequest) (ReconciliationLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "RECONCILIATION_VERSION_REQUIRED", Message: "差异记录版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 500 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 500 字"}
|
||||
}
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return request, clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以整理差异审计归档"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
normalized, err := normalizeReconciliationLifecycleRequest(ctx, request)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.SetReconciliationIssueArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) BatchSetReconciliationIssuesArchived(ctx context.Context, archived bool, request ReconciliationBatchLifecycleRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多整理 20 条差异记录"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量整理中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量整理不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.SetReconciliationIssueArchived(ctx, item.ID, archived, ReconciliationLifecycleRequest{
|
||||
Version: item.Version,
|
||||
Reason: request.Reason,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "整理失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.AssignReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchAssignReconciliationIssues(ctx context.Context, request ReconciliationBatchAssignmentRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{Requested: len(request.Items), Succeeded: []ReconciliationIssue{}, Skipped: []ReconciliationBatchActionFailure{}}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多交接 20 条差异记录"}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 || seen[id] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_ITEM_INVALID", Message: "批量交接包含无效或重复记录"}
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.AssignReconciliationIssue(ctx, item.ID, ReconciliationAssignmentRequest{Version: item.Version, Assignee: request.Assignee, DueAt: request.DueAt})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "交接失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
@@ -69,6 +335,61 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ
|
||||
return store.UpdateReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchUpdateReconciliationIssues(ctx context.Context, request ReconciliationBatchActionRequest) (ReconciliationBatchActionResult, error) {
|
||||
request.Status = strings.TrimSpace(request.Status)
|
||||
request.Note = strings.TrimSpace(request.Note)
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多处置 20 条差异记录"}
|
||||
}
|
||||
allowed := map[string]bool{"pending": true, "no_action": true, "fixed": true}
|
||||
if !allowed[request.Status] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_STATUS_INVALID", Message: "批量处置仅支持退回待复核、无需处理或已修复"}
|
||||
}
|
||||
if request.Status != "pending" && request.Note == "" {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "批量处置必须填写说明"}
|
||||
}
|
||||
if len([]rune(request.Note)) > 500 {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量处置中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量处置不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.UpdateReconciliationIssue(ctx, strings.TrimSpace(item.ID), ReconciliationActionRequest{
|
||||
Version: item.Version,
|
||||
Status: request.Status,
|
||||
Note: request.Note,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: strings.TrimSpace(item.ID), Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "处置失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code = itemError.Code
|
||||
failure.Message = itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -16,8 +17,11 @@ const reconciliationFindingLimit = 50000
|
||||
type ReconciliationStore interface {
|
||||
ReconciliationSummary(context.Context, int) (ReconciliationSummary, error)
|
||||
ReconciliationIssues(context.Context, ReconciliationQuery) (Page[ReconciliationIssue], error)
|
||||
ReconciliationAssignees(context.Context) ([]ReconciliationAssignee, error)
|
||||
ReconciliationIssue(context.Context, string) (ReconciliationIssue, error)
|
||||
UpdateReconciliationIssue(context.Context, string, ReconciliationActionRequest) (ReconciliationIssue, error)
|
||||
AssignReconciliationIssue(context.Context, string, ReconciliationAssignmentRequest) (ReconciliationIssue, error)
|
||||
SetReconciliationIssueArchived(context.Context, string, bool, ReconciliationLifecycleRequest) (ReconciliationIssue, error)
|
||||
EvaluateReconciliation(context.Context) (ReconciliationEvaluationResult, error)
|
||||
}
|
||||
|
||||
@@ -36,9 +40,10 @@ type reconciliationFinding struct {
|
||||
}
|
||||
|
||||
type reconciliationExisting struct {
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
Archived bool
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
|
||||
@@ -118,26 +123,31 @@ issue_id,action,from_status,to_status,actor,note
|
||||
continue
|
||||
}
|
||||
nextStatus := current.Status
|
||||
if current.Status == "recovered" || current.Status == "fixed" {
|
||||
if current.Archived || current.Status == "recovered" || current.Status == "fixed" {
|
||||
nextStatus = "pending"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
rule_code=?,category=?,severity=?,status=?,vin=?,plate=?,protocol_a=?,protocol_b=?,title=?,summary=?,evidence_json=?,
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,version=version+1
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=?`, finding.RuleCode, finding.Category, finding.Severity, nextStatus, finding.VIN, finding.Plate,
|
||||
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, current.ID); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
if nextStatus != current.Status {
|
||||
if nextStatus != current.Status || current.Archived {
|
||||
note := "已恢复或已修复的差异再次出现"
|
||||
if current.Archived {
|
||||
note = "已归档差异再次出现,自动恢复到当前队列等待复核"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator','已恢复或已修复的差异再次出现')`, current.ID, current.Status, nextStatus); err != nil {
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator',?)`, current.ID, current.Status, nextStatus, note); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for fingerprint, current := range existing {
|
||||
if seen[fingerprint] || !reconciliationAutoRecoverable(current.Status) {
|
||||
if seen[fingerprint] || current.Archived || !reconciliationAutoRecoverable(current.Status) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
@@ -152,7 +162,7 @@ issue_id,action,from_status,to_status,actor,note
|
||||
result.Recovered++
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
ruleCountsJSON, _ := json.Marshal(result.RuleCounts)
|
||||
@@ -181,7 +191,7 @@ func reconciliationFingerprint(finding reconciliationFinding) string {
|
||||
}
|
||||
|
||||
func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]reconciliationExisting, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +200,7 @@ func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]rec
|
||||
for rows.Next() {
|
||||
var fingerprint string
|
||||
var item reconciliationExisting
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version); err != nil {
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version, &item.Archived); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[fingerprint] = item
|
||||
@@ -380,12 +390,15 @@ LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
|
||||
|
||||
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
where := []string{"i.archived_at IS NULL"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
where[0] = "i.archived_at IS NOT NULL"
|
||||
}
|
||||
if value := strings.TrimSpace(query.Keyword); value != "" {
|
||||
like := "%" + value + "%"
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ? OR i.archived_by LIKE ? OR i.archive_reason LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"i.rule_code": query.RuleCode, "i.category": query.Category, "i.severity": query.Severity,
|
||||
@@ -401,13 +414,28 @@ func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where = append(where, "i.status=?")
|
||||
args = append(args, status)
|
||||
}
|
||||
if owner := strings.TrimSpace(query.Owner); owner == "unassigned" {
|
||||
where = append(where, "i.assignee=''")
|
||||
} else if owner == "assigned" {
|
||||
where = append(where, "i.assignee<>''")
|
||||
} else if owner != "" && owner != "all" {
|
||||
where = append(where, "i.assignee=?")
|
||||
args = append(args, owner)
|
||||
}
|
||||
if sla := strings.TrimSpace(query.SLA); sla == "overdue" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at<NOW() AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
} else if sla == "due_soon" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at>=NOW() AND i.due_at<=DATE_ADD(NOW(),INTERVAL 8 HOUR) AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b,
|
||||
i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'),
|
||||
DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count,
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,
|
||||
i.assignee,i.assigned_by,COALESCE(DATE_FORMAT(i.assigned_at,'%Y-%m-%d %H:%i:%s'),''),COALESCE(DATE_FORMAT(i.due_at,'%Y-%m-%d %H:%i:%s'),''),
|
||||
COALESCE(DATE_FORMAT(i.archived_at,'%Y-%m-%d %H:%i:%s'),''),i.archived_by,i.archive_reason,i.version
|
||||
FROM vehicle_reconciliation_issue i `
|
||||
|
||||
func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) {
|
||||
@@ -415,7 +443,9 @@ func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (Reconcili
|
||||
var evidence string
|
||||
err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate,
|
||||
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt,
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version)
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy,
|
||||
&item.Assignee, &item.AssignedBy, &item.AssignedAt, &item.DueAt,
|
||||
&item.ArchivedAt, &item.ArchivedBy, &item.ArchiveReason, &item.Version)
|
||||
if err == nil {
|
||||
item.Evidence = map[string]any{}
|
||||
err = json.Unmarshal([]byte(evidence), &item.Evidence)
|
||||
@@ -439,8 +469,12 @@ func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query Reconc
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
orderBy := "FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC"
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
orderBy = "i.archived_at DESC,i.id DESC"
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+`
|
||||
ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...)
|
||||
ORDER BY `+orderBy+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
@@ -456,6 +490,79 @@ ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id D
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationAssignees(ctx context.Context) ([]ReconciliationAssignee, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]ReconciliationAssignee{}
|
||||
accountRows, err := s.db.QueryContext(ctx, `SELECT display_name,username FROM platform_user
|
||||
WHERE user_type='admin' AND status='enabled' ORDER BY display_name,username LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for accountRows.Next() {
|
||||
var item ReconciliationAssignee
|
||||
if err := accountRows.Scan(&item.Name, &item.Username); err != nil {
|
||||
accountRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Username = strings.TrimSpace(item.Username)
|
||||
item.Source = "account"
|
||||
if item.Name == "" {
|
||||
item.Name = item.Username
|
||||
}
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if err := accountRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
historyRows, err := s.db.QueryContext(ctx, `SELECT assignee,
|
||||
SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),
|
||||
COALESCE(DATE_FORMAT(MAX(assigned_at),'%Y-%m-%d %H:%i:%s'),'')
|
||||
FROM vehicle_reconciliation_issue WHERE assignee<>'' AND archived_at IS NULL GROUP BY assignee
|
||||
ORDER BY MAX(assigned_at) DESC,assignee LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for historyRows.Next() {
|
||||
var name, lastAssignedAt string
|
||||
var activeCount int
|
||||
if err := historyRows.Scan(&name, &activeCount, &lastAssignedAt); err != nil {
|
||||
historyRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "history"}
|
||||
}
|
||||
item.ActiveCount = activeCount
|
||||
item.LastAssignedAt = lastAssignedAt
|
||||
byName[key] = item
|
||||
}
|
||||
if err := historyRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
@@ -511,7 +618,8 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion); err != nil {
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
@@ -520,6 +628,9 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
status=?,resolution_note=?,resolved_by=?,
|
||||
recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END,
|
||||
@@ -543,6 +654,115 @@ issue_id,action,from_status,to_status,actor,note
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
request.Assignee = strings.TrimSpace(request.Assignee)
|
||||
if request.Assignee == "" || len([]rune(request.Assignee)) > 128 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNEE_INVALID", Message: "请输入有效负责人"}
|
||||
}
|
||||
dueAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.DueAt))
|
||||
if err != nil || !dueAt.After(time.Now()) {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_DUE_AT_INVALID", Message: "请选择有效的未来处理期限"}
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if currentStatus != "pending" && currentStatus != "confirmed_source_a" && currentStatus != "confirmed_source_b" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET assignee=?,assigned_by=?,assigned_at=NOW(3),due_at=?,version=version+1 WHERE id=? AND version=?`, request.Assignee, request.Actor, dueAt.UTC(), id, request.Version)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"}
|
||||
}
|
||||
note := fmt.Sprintf("交接给 %s,处理期限 %s", request.Assignee, dueAt.In(time.Local).Format("2006-01-02 15:04"))
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(issue_id,action,from_status,to_status,actor,note) VALUES(?,'assign','','',?,?)`, id, request.Actor, note); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var currentlyArchived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL
|
||||
FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, ¤tlyArchived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if currentStatus != "fixed" && currentStatus != "no_action" && currentStatus != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1
|
||||
WHERE id=? AND version=?`, request.Actor, request.Reason, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=? AND version=?`, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,?,?,?,?,?)`, id, action, currentStatus, currentStatus, request.Actor, request.Reason); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'),
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
|
||||
SUM(new_count),
|
||||
@@ -561,12 +781,14 @@ func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) (
|
||||
}
|
||||
var result ReconciliationSummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='pending'),0),
|
||||
COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='recovered'),0),
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at<DATE_SUB(NOW(),INTERVAL 24 HOUR)),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
COALESCE(SUM(archived_at IS NULL),0),
|
||||
COALESCE(SUM(archived_at IS NOT NULL),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='pending'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='recovered'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b') AND COALESCE(due_at,DATE_ADD(first_seen_at,INTERVAL 24 HOUR))<NOW()),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Current, &result.Archived, &result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
return ReconciliationSummary{}, err
|
||||
}
|
||||
var err error
|
||||
@@ -600,7 +822,7 @@ func (s *ProductionStore) reconciliationBuckets(ctx context.Context, column stri
|
||||
return nil, fmt.Errorf("unsupported reconciliation bucket")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,193 @@ func exportAdminContext() context.Context {
|
||||
return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"})
|
||||
}
|
||||
|
||||
type concurrentHistoryStore struct {
|
||||
*MockStore
|
||||
mu sync.Mutex
|
||||
active int
|
||||
max int
|
||||
}
|
||||
|
||||
func (s *concurrentHistoryStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
if s.active > s.max {
|
||||
s.max = s.active
|
||||
}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
|
||||
}
|
||||
|
||||
func TestHistoryDataQueriesMultipleVehiclesConcurrentlyAndPreservesReceiptOrder(t *testing.T) {
|
||||
store := &concurrentHistoryStore{MockStore: NewMockStore()}
|
||||
response, err := NewService(store).HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"粤AG18312,川AHTWO1,豫A88888"},
|
||||
"category": {"location"},
|
||||
"dateFrom": {"2026-06-23T00:00"},
|
||||
"dateTo": {"2026-07-23T00:00"},
|
||||
"limit": {"10"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.max < 2 {
|
||||
t.Fatalf("multi-vehicle history requests should overlap, max concurrency=%d", store.max)
|
||||
}
|
||||
want := []string{"粤AG18312", "川AHTWO1", "豫A88888"}
|
||||
if len(response.Summary.Vehicles) != len(want) {
|
||||
t.Fatalf("unexpected receipt count: %+v", response.Summary.Vehicles)
|
||||
}
|
||||
for index, keyword := range want {
|
||||
if response.Summary.Vehicles[index].Keyword != keyword {
|
||||
t.Fatalf("receipt order changed at %d: %+v", index, response.Summary.Vehicles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdateReconciliationIssuesValidatesScopeAndKeepsPartialSuccess(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "批量处置员", Role: "operator", UserType: "operator"})
|
||||
result, err := service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
|
||||
Items: []ReconciliationBatchActionItem{
|
||||
{ID: "reconciliation-demo-position", Version: 1},
|
||||
{ID: "missing-issue", Version: 1},
|
||||
},
|
||||
Status: "no_action",
|
||||
Note: "已核对本页所选问题,确认无需修改原始数据",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if result.Succeeded[0].ResolvedBy != "批量处置员" || result.Skipped[0].Code != "RECONCILIATION_NOT_FOUND" {
|
||||
t.Fatalf("unexpected per-item evidence: %+v", result)
|
||||
}
|
||||
|
||||
_, err = service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
|
||||
Items: []ReconciliationBatchActionItem{{ID: "reconciliation-demo-position", Version: 2}, {ID: "reconciliation-demo-position", Version: 2}},
|
||||
Status: "fixed",
|
||||
Note: "重复选择应在写入前被拒绝",
|
||||
})
|
||||
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_BATCH_DUPLICATE_ID" {
|
||||
t.Fatalf("duplicate ids should be rejected, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationDirectoryIncludesCurrentPrincipalAndHistoricalOwners(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "当前值班员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if _, err := service.AssignReconciliationIssue(ctx, "reconciliation-demo-position", ReconciliationAssignmentRequest{Version: 1, Assignee: "定位运维组", DueAt: dueAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.ReconciliationAssignees(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || !items[0].Current || items[0].Name != "当前值班员" || items[1].Name != "定位运维组" || items[1].ActiveCount != 1 {
|
||||
t.Fatalf("unexpected assignee directory: %+v", items)
|
||||
}
|
||||
filtered, err := service.ReconciliationAssignees(ctx, "operator-a")
|
||||
if err != nil || len(filtered) != 1 || filtered[0].Name != "当前值班员" {
|
||||
t.Fatalf("search should match username: items=%+v err=%v", filtered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportReconciliationIssuesUsesFullFilterAndSafeCSV(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
store.reconciliationIssues[0].Plate = "=FORMULA"
|
||||
service := NewService(store)
|
||||
file, err := service.ExportReconciliationIssues(context.Background(), ReconciliationQuery{Status: "active", Owner: "unassigned"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(file.Content)
|
||||
if file.RowCount != 1 || !strings.HasPrefix(text, "\xEF\xBB\xBF") || !strings.Contains(text, "差异编号,等级,状态") || !strings.Contains(text, "'=FORMULA") {
|
||||
t.Fatalf("unexpected export file: rows=%d name=%q content=%q", file.RowCount, file.Name, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationArchiveLifecycleSeparatesCurrentAndAuditScopes(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Username: "quality-admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
archived, err := service.SetReconciliationIssueArchived(admin, "reconciliation-demo-source", true, ReconciliationLifecycleRequest{
|
||||
Version: 2,
|
||||
Reason: "问题已自动恢复并完成月度复核,转入长期审计留存",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == "" || archived.ArchivedBy != "质量管理员" || archived.Version != 3 {
|
||||
t.Fatalf("archive receipt incomplete: %+v", archived)
|
||||
}
|
||||
current, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "current", Status: "all", Limit: 20})
|
||||
if err != nil || current.Total != 1 {
|
||||
t.Fatalf("current scope should exclude archived issue: page=%+v err=%v", current, err)
|
||||
}
|
||||
audit, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "archived", Keyword: "月度复核", Status: "all", Limit: 20})
|
||||
if err != nil || audit.Total != 1 || audit.Items[0].ID != archived.ID {
|
||||
t.Fatalf("archive scope should search audit evidence: page=%+v err=%v", audit, err)
|
||||
}
|
||||
if _, err := service.UpdateReconciliationIssue(admin, archived.ID, ReconciliationActionRequest{Version: archived.Version, Status: "pending"}); err == nil {
|
||||
t.Fatal("archived issue must be read-only")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_ARCHIVED_READ_ONLY" {
|
||||
t.Fatalf("unexpected read-only error: %v", err)
|
||||
}
|
||||
|
||||
operator := WithPrincipal(context.Background(), Principal{Name: "质量操作员", Role: "operator", UserType: "operator"})
|
||||
if _, err := service.SetReconciliationIssueArchived(operator, archived.ID, false, ReconciliationLifecycleRequest{Version: archived.Version, Reason: "重新进入当前队列复核"}); err == nil {
|
||||
t.Fatal("operator must not restore audit records")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected permission error: %v", err)
|
||||
}
|
||||
|
||||
restored, err := service.SetReconciliationIssueArchived(admin, archived.ID, false, ReconciliationLifecycleRequest{
|
||||
Version: archived.Version,
|
||||
Reason: "收到新的来源接入证据,需要重新核对",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ArchivedAt != "" || restored.Status != "recovered" || restored.Version != 4 {
|
||||
t.Fatalf("restore must preserve conclusion while returning to current scope: %+v", restored)
|
||||
}
|
||||
if len(restored.Actions) < 2 || restored.Actions[len(restored.Actions)-1].Action != "restore" {
|
||||
t.Fatalf("restore audit missing: %+v", restored.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReconciliationArchiveKeepsPartialFailuresRecoverable(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Role: "admin", UserType: "admin"})
|
||||
result, err := service.BatchSetReconciliationIssuesArchived(admin, true, ReconciliationBatchLifecycleRequest{
|
||||
Items: []ReconciliationBatchActionItem{
|
||||
{ID: "reconciliation-demo-source", Version: 2},
|
||||
{ID: "reconciliation-demo-position", Version: 1},
|
||||
},
|
||||
Reason: "已完成周期复核,批量整理到审计归档",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
|
||||
t.Fatalf("unexpected batch archive result: %+v", result)
|
||||
}
|
||||
if result.Succeeded[0].ID != "reconciliation-demo-source" || result.Skipped[0].Code != "RECONCILIATION_ARCHIVE_OPEN_ISSUE" {
|
||||
t.Fatalf("batch archive should keep open issue unchanged: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
@@ -51,6 +238,309 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageSearchesAndPaginatesBeyondFirstTwenty(t *testing.T) {
|
||||
exportDir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: exportDir})
|
||||
now := time.Now().UTC()
|
||||
service.exportsMu.Lock()
|
||||
for index := 0; index < 36; index++ {
|
||||
status := "completed"
|
||||
if index%5 == 0 {
|
||||
status = "failed"
|
||||
}
|
||||
id := fmt.Sprintf("exp_%02d", index+1)
|
||||
filePath := ""
|
||||
if status == "completed" {
|
||||
filePath = filepath.Join(exportDir, id+".csv")
|
||||
if err := os.WriteFile(filePath, []byte("vin\n"), 0o640); err != nil {
|
||||
service.exportsMu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ownerID := "local:subject:1"
|
||||
ownerUsername := "admin"
|
||||
if index >= 30 {
|
||||
ownerID = "local:subject:2"
|
||||
ownerUsername = "audit-peer"
|
||||
}
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: fmt.Sprintf("历史归档 %02d", index+1), Status: status, Category: "location", Format: "csv",
|
||||
Keywords: []string{fmt.Sprintf("VIN%03d", index+1)}, VehicleVINs: []string{fmt.Sprintf("VIN%03d", index+1)},
|
||||
OwnerID: ownerID, OwnerUsername: ownerUsername, CreatedAt: now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339),
|
||||
filePath: filePath,
|
||||
}
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 10, Offset: 20})
|
||||
if page.Total != 36 || len(page.Items) != 10 || page.Items[0].ID != "exp_21" || page.Summary.Total != 36 || page.Summary.Recoverable != 8 {
|
||||
t.Fatalf("unexpected page: %+v", page)
|
||||
}
|
||||
filtered := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Search: "VIN035", Status: "completed", Limit: 10})
|
||||
if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0].ID != "exp_35" {
|
||||
t.Fatalf("unexpected filtered page: %+v", filtered)
|
||||
}
|
||||
mine := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
|
||||
if mine.Total != 30 || len(mine.Items) != 10 || mine.Summary.Total != 30 || mine.Items[0].OwnerUsername != "admin" {
|
||||
t.Fatalf("admin mine scope should isolate the current account without weakening global audit access: %+v", mine)
|
||||
}
|
||||
emptyMine := service.ListHistoryExportsPage(WithPrincipal(context.Background(), Principal{SubjectID: "missing-admin", Username: "missing-admin", Role: "admin", UserType: "admin", AuthProvider: "local"}), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
|
||||
if emptyMine.Items == nil || emptyMine.Total != 0 || emptyMine.Summary.Total != 0 {
|
||||
t.Fatalf("empty owner scope should return a stable empty collection and zero summary: %+v", emptyMine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageKeepsStableDeepPaginationAcrossTenThousandTasks(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
now := time.Now().UTC()
|
||||
service.exportsMu.Lock()
|
||||
for index := 0; index < 10_005; index++ {
|
||||
id := fmt.Sprintf("scale_%05d", index+1)
|
||||
createdAt := now.Add(-time.Duration(index) * time.Second).Format(time.RFC3339)
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: "规模任务 " + id, Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: createdAt, UpdatedAt: createdAt, CancelledAt: createdAt, CancelledBy: "admin",
|
||||
}
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 9_950})
|
||||
if page.Total != 10_005 || page.Limit != 50 || page.Offset != 9_950 || len(page.Items) != 50 {
|
||||
t.Fatalf("unexpected deep page metadata: total=%d limit=%d offset=%d items=%d", page.Total, page.Limit, page.Offset, len(page.Items))
|
||||
}
|
||||
if page.Items[0].ID != "scale_09951" || page.Items[49].ID != "scale_10000" || page.Summary.Cancelled != 10_005 {
|
||||
t.Fatalf("deep page must keep stable order and complete summary: first=%s last=%s summary=%+v", page.Items[0].ID, page.Items[49].ID, page.Summary)
|
||||
}
|
||||
tail := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 10_000})
|
||||
if tail.Total != 10_005 || len(tail.Items) != 5 || tail.Items[0].ID != "scale_10001" || tail.Items[4].ID != "scale_10005" {
|
||||
t.Fatalf("unexpected tail page: %+v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportArchiveSeparatesCurrentWorkFromAuditRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
nowTime := time.Now().UTC()
|
||||
now := nowTime.Format(time.RFC3339)
|
||||
archiveFile := filepath.Join(dir, "exp_completed_archive.csv")
|
||||
if err := os.WriteFile(archiveFile, []byte("vin\nVIN-ARCHIVE\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exportsMu.Lock()
|
||||
service.exports["exp_completed_archive"] = &HistoryExportJob{
|
||||
ID: "exp_completed_archive", Name: "月度历史审计包", Status: "completed", Format: "csv", Category: "location",
|
||||
Keywords: []string{"VIN-ARCHIVE"}, VehicleVINs: []string{"VIN-ARCHIVE"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
DateFrom: "2026-06-01T00:00", DateTo: "2026-06-30T23:59", CreatedAt: now, UpdatedAt: now, CompletedAt: now,
|
||||
ExpiresAt: nowTime.Add(12 * time.Hour).Format(time.RFC3339), Evidence: "车辆范围与账号权限已固化", filePath: archiveFile,
|
||||
}
|
||||
service.exports["exp_running_visible"] = &HistoryExportJob{
|
||||
ID: "exp_running_visible", Name: "执行中任务", Status: "running", Format: "csv", Category: "location",
|
||||
Keywords: []string{"VIN-RUNNING"}, VehicleVINs: []string{"VIN-RUNNING"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
archived, err := service.SetHistoryExportArchived(ctx, "exp_completed_archive", true)
|
||||
if err != nil || archived.ArchivedAt == "" || archived.ArchivedBy != "admin" || !strings.Contains(archived.Evidence, "归档") {
|
||||
t.Fatalf("archive failed: job=%+v err=%v", archived, err)
|
||||
}
|
||||
current := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "current", Limit: 10})
|
||||
if current.Total != 1 || len(current.Items) != 1 || current.Items[0].ID != "exp_running_visible" || current.Summary.Current != 1 || current.Summary.Archived != 1 || current.Summary.Active != 1 || current.Summary.Completed != 0 || current.Summary.Expiring != 0 {
|
||||
t.Fatalf("current queue should exclude archived audit records: %+v", current)
|
||||
}
|
||||
audit := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Search: "车辆范围与账号权限", Limit: 10})
|
||||
if audit.Total != 1 || len(audit.Items) != 1 || audit.Items[0].ID != "exp_completed_archive" || audit.Summary.Active != 0 || audit.Summary.Completed != 1 || audit.Summary.Expiring != 1 {
|
||||
t.Fatalf("archived evidence should remain searchable: %+v", audit)
|
||||
}
|
||||
expiring := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Status: "expiring", Limit: 10})
|
||||
if expiring.Total != 1 || len(expiring.Items) != 1 || expiring.Items[0].ID != "exp_completed_archive" {
|
||||
t.Fatalf("expiring filter should isolate downloadable files inside the selected archive scope: %+v", expiring)
|
||||
}
|
||||
if _, err := service.SetHistoryExportArchived(ctx, "exp_running_visible", true); err == nil {
|
||||
t.Fatal("active jobs must remain visible and cannot be archived")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_NOT_ARCHIVABLE" {
|
||||
t.Fatalf("unexpected active archive error: %v", err)
|
||||
}
|
||||
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_completed_archive"}, Action: "restore"})
|
||||
if err != nil || len(batch.Succeeded) != 1 || batch.Succeeded[0].ArchivedAt != "" || !strings.Contains(batch.Succeeded[0].Evidence, "移回当前") {
|
||||
t.Fatalf("restore batch failed: result=%+v err=%v", batch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupPreviewsProtectsAndAuditsPermanentRemoval(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
now := time.Now().UTC()
|
||||
oldArchive := now.Add(-200 * 24 * time.Hour).Format(time.RFC3339)
|
||||
recentArchive := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
service.exports["cleanup-old"] = &HistoryExportJob{
|
||||
ID: "cleanup-old", Name: "待清理年度包", Status: "expired", Category: "location", Format: "csv",
|
||||
Keywords: []string{"VIN-CLEAN"}, VehicleVINs: []string{"VIN-CLEAN"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 2048, Evidence: "原范围保留",
|
||||
}
|
||||
service.exports["cleanup-protected"] = &HistoryExportJob{
|
||||
ID: "cleanup-protected", Name: "长期合规包", Status: "expired", Category: "raw", Format: "csv",
|
||||
Keywords: []string{"VIN-PROTECT"}, VehicleVINs: []string{"VIN-PROTECT"}, OwnerID: "local:subject:other", OwnerUsername: "audit-peer",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 4096, Evidence: "原范围保留",
|
||||
CleanupProtectedAt: now.Add(-24 * time.Hour).Format(time.RFC3339), CleanupProtectedBy: "admin", CleanupProtectionReason: "年度监管复核",
|
||||
}
|
||||
service.exports["cleanup-recent"] = &HistoryExportJob{
|
||||
ID: "cleanup-recent", Name: "近期归档", Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{"VIN-RECENT"}, VehicleVINs: []string{"VIN-RECENT"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: recentArchive, UpdatedAt: recentArchive, ArchivedAt: recentArchive, ArchivedBy: "admin", Evidence: "近期归档",
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
preview, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.ArchivedCount != 3 || preview.CandidateCount != 1 || preview.PlannedCount != 1 || preview.ProtectedCount != 1 || len(preview.Candidates) != 1 || preview.Candidates[0].ID != "cleanup-old" || len(preview.Protected) != 1 || preview.Protected[0].ID != "cleanup-protected" {
|
||||
t.Fatalf("unexpected cleanup preview: %+v", preview)
|
||||
}
|
||||
|
||||
protected, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: true, Reason: "诉讼证据保全"})
|
||||
if err != nil || protected.CleanupProtectedAt == "" || protected.CleanupProtectionReason != "诉讼证据保全" || !strings.Contains(protected.Evidence, "长期保留") {
|
||||
t.Fatalf("protect cleanup candidate failed: job=%+v err=%v", protected, err)
|
||||
}
|
||||
if _, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: preview.PreviewToken}); err == nil {
|
||||
t.Fatal("stale preview token must be rejected after protection changes")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_PREVIEW_STALE" {
|
||||
t.Fatalf("unexpected stale preview error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
|
||||
if err != nil || fresh.CandidateCount != 1 || fresh.ProtectedCount != 1 {
|
||||
t.Fatalf("unexpected refreshed cleanup preview: %+v err=%v", fresh, err)
|
||||
}
|
||||
result, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: fresh.PreviewToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Record.Cleaned != 1 || result.Record.Protected != 1 || result.Record.Actor != "admin" || len(result.Deleted) != 1 || result.Deleted[0].ID != "cleanup-old" {
|
||||
t.Fatalf("unexpected cleanup result: %+v", result)
|
||||
}
|
||||
page := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Limit: 10})
|
||||
if page.Total != 2 || page.Summary.Archived != 2 {
|
||||
t.Fatalf("cleanup must leave protected and recent records: %+v", page)
|
||||
}
|
||||
audit, err := service.HistoryExportCleanupAudit(ctx, 20)
|
||||
if err != nil || len(audit) != 1 || audit[0].ID != result.Record.ID || audit[0].CandidateDigest != fresh.PreviewToken {
|
||||
t.Fatalf("cleanup audit must remain independently searchable: %+v err=%v", audit, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloadedAudit, err := reloaded.HistoryExportCleanupAudit(ctx, 20)
|
||||
if err != nil || len(reloadedAudit) != 1 || reloadedAudit[0].ID != result.Record.ID {
|
||||
t.Fatalf("cleanup audit must survive restart: %+v err=%v", reloadedAudit, err)
|
||||
}
|
||||
if _, exists := reloaded.historyExportJob("cleanup-old"); exists {
|
||||
t.Fatal("cleaned record must not return after restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupRequiresAdministratorAndPreservesMoreThanFiveHundredRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
oldArchive := time.Now().UTC().Add(-400 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
for index := 1; index <= 550; index++ {
|
||||
id := fmt.Sprintf("persist-%03d", index)
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: id, Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin",
|
||||
}
|
||||
}
|
||||
if err := service.persistHistoryExportsLocked(); err != nil {
|
||||
service.exportsMu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
if page := reloaded.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Limit: 50}); page.Summary.Archived != 550 {
|
||||
t.Fatalf("explicit cleanup must replace silent 500-record truncation: %+v", page.Summary)
|
||||
}
|
||||
operator := WithPrincipal(context.Background(), Principal{SubjectID: "operator-1", Username: "operator", Role: "operator", UserType: "operator", AuthProvider: "local"})
|
||||
if _, err := reloaded.PreviewHistoryExportCleanup(operator, HistoryExportCleanupQuery{OlderThanDays: 365}); err == nil {
|
||||
t.Fatal("operator must not preview permanent archive cleanup")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_ADMIN_REQUIRED" {
|
||||
t.Fatalf("unexpected cleanup permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageSortsByScopeActivityAndFutureExpiry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
now := time.Now().UTC()
|
||||
writeJob := func(id string, createdAt, archivedAt, expiresAt time.Time) {
|
||||
path := filepath.Join(dir, id+".csv")
|
||||
if err := os.WriteFile(path, []byte("vin\n"+id+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: id, Status: "completed", Format: "csv", Category: "location",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: createdAt.Format(time.RFC3339), UpdatedAt: archivedAt.Format(time.RFC3339), CompletedAt: createdAt.Format(time.RFC3339),
|
||||
ArchivedAt: archivedAt.Format(time.RFC3339), ExpiresAt: expiresAt.Format(time.RFC3339), filePath: path,
|
||||
}
|
||||
}
|
||||
service.exportsMu.Lock()
|
||||
writeJob("old-created-recently-archived", now.Add(-72*time.Hour), now.Add(-time.Minute), now.Add(12*time.Hour))
|
||||
writeJob("new-created-older-archive", now.Add(-24*time.Hour), now.Add(-2*time.Hour), now.Add(4*time.Hour))
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
recent := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "recent", Limit: 10})
|
||||
if len(recent.Items) != 2 || recent.Items[0].ID != "old-created-recently-archived" {
|
||||
t.Fatalf("archived scope should prioritize the latest archive activity, not original creation time: %+v", recent.Items)
|
||||
}
|
||||
expiry := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "expiry", Limit: 10})
|
||||
if len(expiry.Items) != 2 || expiry.Items[0].ID != "new-created-older-archive" {
|
||||
t.Fatalf("expiry sort should prioritize the first downloadable file to expire: %+v", expiry.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
owner := exportAdminContext()
|
||||
preference, err := service.UpdateHistoryPreferences(owner, HistoryPreferences{
|
||||
RetentionDays: 30,
|
||||
FieldViews: []HistoryFieldView{{ID: "location:交付核验", Name: "交付核验", Category: "location", Keys: []string{"speedKmh", "socPercent"}}},
|
||||
})
|
||||
if err != nil || preference.Revision != 1 || preference.RetentionDays != 30 || len(preference.FieldViews) != 1 {
|
||||
t.Fatalf("save preferences: preference=%+v err=%v", preference, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
restored, err := reloaded.HistoryPreferences(owner)
|
||||
if err != nil || restored.RetentionDays != 30 || len(restored.FieldViews) != 1 || restored.FieldViews[0].Name != "交付核验" {
|
||||
t.Fatalf("restored preferences: preference=%+v err=%v", restored, err)
|
||||
}
|
||||
|
||||
reloaded.exportSlots <- struct{}{}
|
||||
job, err := reloaded.CreateHistoryExport(owner, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
|
||||
if err != nil {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.RetentionDays != 30 || !strings.Contains(job.Evidence, "文件保留 30 天") {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatalf("account retention not applied: %+v", job)
|
||||
}
|
||||
if _, err := reloaded.CancelHistoryExport(owner, job.ID); err != nil {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-reloaded.exportSlots
|
||||
}
|
||||
|
||||
type countingStore struct {
|
||||
*MockStore
|
||||
vehiclesCalls int
|
||||
@@ -59,14 +549,81 @@ type countingStore struct {
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
lastHistoryQuery url.Values
|
||||
historyQueries []url.Values
|
||||
rawFrameQueries []RawFrameQuery
|
||||
lastDailyMileageQuery url.Values
|
||||
lastMileageStatisticsQuery url.Values
|
||||
}
|
||||
|
||||
type hydrogenMetricsStore struct{ *MockStore }
|
||||
|
||||
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
return Page[DailyMileageRow]{Items: []DailyMileageRow{{
|
||||
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
|
||||
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (MileageSummary, error) {
|
||||
return MileageSummary{TotalMileageKm: 88.7, TotalPureHydrogenMileageKm: 56.2}, nil
|
||||
}
|
||||
|
||||
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
return MileageStatistics{
|
||||
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
Trend: []MileageTrendPoint{{
|
||||
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
return &countingStore{MockStore: NewMockStore()}
|
||||
}
|
||||
|
||||
func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T) {
|
||||
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
customer := WithPrincipal(context.Background(), Principal{
|
||||
Name: "业务客户", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
})
|
||||
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
|
||||
|
||||
daily, err := service.DailyMileage(customer, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil {
|
||||
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
summary, err := service.MileageStatistics(customer, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.PeriodPureHydrogenMileageKm != 0 || summary.HydrogenMatchedMileageKm != 0 || summary.HydrogenDataDays != 0 || summary.PeriodHydrogenConsumptionKg != 0 || summary.HydrogenConsumptionKgPer100Km != nil ||
|
||||
len(summary.Trend) != 1 || summary.Trend[0].PureHydrogenMileageKm != 0 || summary.Trend[0].HydrogenMatchedMileageKm != 0 || summary.Trend[0].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("customer statistics exposed hydrogen metrics: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.T) {
|
||||
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
|
||||
daily, err := service.DailyMileage(exportAdminContext(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
|
||||
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
s.vehiclesCalls++
|
||||
s.lastVehicleQuery = cloneValues(query)
|
||||
@@ -81,14 +638,60 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (
|
||||
|
||||
func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.lastHistoryQuery = cloneValues(query)
|
||||
s.historyQueries = append(s.historyQueries, cloneValues(query))
|
||||
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
s.rawFrameQueries = append(s.rawFrameQueries, query)
|
||||
return s.MockStore.RawFrames(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
||||
s.lastDailyMileageQuery = cloneValues(query)
|
||||
return s.MockStore.DailyMileage(ctx, query)
|
||||
}
|
||||
|
||||
func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !detail.LookupResolved {
|
||||
t.Fatalf("vehicle detail should resolve the requested VIN: %+v", detail)
|
||||
}
|
||||
if len(store.historyQueries) == 0 {
|
||||
t.Fatal("vehicle detail should query bounded TDengine history previews")
|
||||
}
|
||||
for _, query := range store.historyQueries {
|
||||
if query.Get("skipCount") != "1" {
|
||||
t.Fatalf("every vehicle detail history preview should skip the full TDengine count: %+v", store.historyQueries)
|
||||
}
|
||||
}
|
||||
rawPreviewFound := false
|
||||
for _, query := range store.rawFrameQueries {
|
||||
if query.IncludeFields && query.Limit == 10 {
|
||||
rawPreviewFound = true
|
||||
}
|
||||
if !query.SkipCount {
|
||||
t.Fatalf("every vehicle detail raw preview should skip the full TDengine count: %+v", store.rawFrameQueries)
|
||||
}
|
||||
}
|
||||
if !rawPreviewFound {
|
||||
t.Fatalf("vehicle detail raw preview was not queried: %+v", store.rawFrameQueries)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("vins"); got != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("vehicle detail mileage preview should use an exact VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("vin"); got != "" {
|
||||
t.Fatalf("vehicle detail mileage preview must not use the fuzzy VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("skipCount"); got != "1" {
|
||||
t.Fatalf("vehicle detail mileage preview should skip its unused count, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
s.lastMileageStatisticsQuery = cloneValues(query)
|
||||
return s.MockStore.MileageStatistics(ctx, query)
|
||||
@@ -815,6 +1418,133 @@ func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
|
||||
t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA))
|
||||
}
|
||||
|
||||
func TestHistoryExportCancellationIsOwnerScopedAuditedAndPersistent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
vin := "LNXNEGRR7SR318212"
|
||||
owner := exportCustomerContext("101", "customer-a", vin)
|
||||
other := exportCustomerContext("102", "customer-b", vin)
|
||||
|
||||
service.exportSlots <- struct{}{}
|
||||
job, err := service.CreateHistoryExport(owner, HistoryExportRequest{
|
||||
Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
|
||||
})
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.CancelHistoryExport(other, job.ID); err == nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal("another customer should not cancel the owner's export")
|
||||
}
|
||||
cancelled, err := service.CancelHistoryExport(owner, job.ID)
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cancelled.Status != "cancelled" || cancelled.CancelledBy != "customer-a" || cancelled.CancelledAt == "" || !strings.Contains(cancelled.Evidence, "主动取消") {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("cancellation audit metadata missing: %+v", cancelled)
|
||||
}
|
||||
<-service.exportSlots
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
jobs := service.ListHistoryExports(owner)
|
||||
if len(jobs) == 1 && jobs[0].Status == "cancelled" {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
jobs := service.ListHistoryExports(owner)
|
||||
if len(jobs) != 1 || jobs[0].Status != "cancelled" {
|
||||
t.Fatalf("cancelled queued task must never start later: %+v", jobs)
|
||||
}
|
||||
if _, _, err := service.HistoryExportFile(owner, job.ID); err == nil {
|
||||
t.Fatal("cancelled export must not expose a download")
|
||||
}
|
||||
if repeated, err := service.CancelHistoryExport(owner, job.ID); err != nil || repeated.Status != "cancelled" {
|
||||
t.Fatalf("cancelling an already cancelled task should be idempotent: job=%+v err=%v", repeated, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloadedJobs := reloaded.ListHistoryExports(owner)
|
||||
if len(reloadedJobs) != 1 || reloadedJobs[0].Status != "cancelled" || reloadedJobs[0].CancelledBy != "customer-a" {
|
||||
t.Fatalf("cancelled state should survive restart: %+v", reloadedJobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportExpiryRebuildAndSafeBatchActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
vin := "LNXNEGRR7SR318212"
|
||||
filePath := filepath.Join(dir, "exp_expired.csv")
|
||||
if err := os.WriteFile(filePath, []byte("vin\n"+vin+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
||||
createdAt := time.Now().UTC().Add(-8 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
service.exports["exp_expired"] = &HistoryExportJob{
|
||||
ID: "exp_expired", Name: "过期任务", Status: "completed", Progress: 100, Format: "csv", Category: "location",
|
||||
Keywords: []string{vin}, Metrics: []string{"speedKmh"}, VehicleVINs: []string{vin}, DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00",
|
||||
OwnerID: "local:subject:1", OwnerUsername: "admin", RowCount: 12, CreatedAt: createdAt, UpdatedAt: createdAt, CompletedAt: createdAt,
|
||||
ExpiresAt: past, DownloadURL: "/api/v2/exports/exp_expired/download", Evidence: "原范围已固化", filePath: filePath,
|
||||
}
|
||||
service.exports["exp_running_batch"] = &HistoryExportJob{
|
||||
ID: "exp_running_batch", Name: "执行中任务", Status: "running", Format: "csv", Category: "location", Keywords: []string{vin},
|
||||
OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt, UpdatedAt: createdAt, Evidence: "执行中",
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
jobs := service.ListHistoryExports(ctx)
|
||||
var expired HistoryExportJob
|
||||
for _, job := range jobs {
|
||||
if job.ID == "exp_expired" {
|
||||
expired = job
|
||||
}
|
||||
}
|
||||
if expired.Status != "expired" || expired.DownloadURL != "" || expired.ExpiredAt == "" || !strings.Contains(expired.Error, "保留期") {
|
||||
t.Fatalf("completed file should become an auditable expired task: %+v", expired)
|
||||
}
|
||||
if _, _, err := service.HistoryExportFile(ctx, expired.ID); err == nil {
|
||||
t.Fatal("expired file should not remain downloadable")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_EXPIRED" {
|
||||
t.Fatalf("expired download error=%v", err)
|
||||
}
|
||||
|
||||
service.exportSlots <- struct{}{}
|
||||
rebuilt, err := service.RebuildHistoryExport(ctx, expired.ID)
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rebuilt.Status != "queued" || rebuilt.RebuiltFrom != expired.ID || !strings.Contains(rebuilt.Evidence, expired.ID) {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("rebuild should create a linked queued task: %+v", rebuilt)
|
||||
}
|
||||
if _, err := service.RebuildHistoryExport(ctx, "exp_running_batch"); err == nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal("active task must not be rebuildable")
|
||||
}
|
||||
|
||||
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_running_batch", "exp_expired"}, Action: "cancel"})
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Requested != 2 || len(batch.Succeeded) != 1 || batch.Succeeded[0].ID != "exp_running_batch" || len(batch.Skipped) != 1 || batch.Skipped[0].Code != "EXPORT_NOT_CANCELLABLE" {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("batch cancellation must explicitly preserve ineligible tasks: %+v", batch)
|
||||
}
|
||||
if _, cancelErr := service.CancelHistoryExport(ctx, rebuilt.ID); cancelErr != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(cancelErr)
|
||||
}
|
||||
<-service.exportSlots
|
||||
}
|
||||
|
||||
type revocableHistoryExportStore struct {
|
||||
*MockStore
|
||||
active bool
|
||||
@@ -969,6 +1699,114 @@ func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHistorySegmentsReuseLatestTelemetryCategorySource(t *testing.T) {
|
||||
definitions := []MetricDefinition{
|
||||
{Key: "speed_kmh", Category: "driving", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}},
|
||||
{Key: "stack_voltage_v", Category: "fuel-cell", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.stack_1.avg_voltage_v"}},
|
||||
}
|
||||
rows := []HistoryDataRow{{Values: map[string]any{
|
||||
"gb32960.vehicle.speed_kmh": 38.0,
|
||||
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
|
||||
"jt808.location.additional.total_mileage_km": 1200.0,
|
||||
}}}
|
||||
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows, definitions)
|
||||
categories := map[string]string{}
|
||||
for _, column := range columns {
|
||||
categories[column.Key] = column.Category
|
||||
}
|
||||
if categories["gb32960.vehicle.speed_kmh"] != "vehicle" || categories["gb32960.fuel_cell.stack_1.avg_voltage_v"] != "fuel-cell" || categories["jt808.location.additional.total_mileage_km"] != "location" {
|
||||
t.Fatalf("raw history did not reuse telemetry categories: %+v", categories)
|
||||
}
|
||||
segments := historyRawSegments(columns)
|
||||
if len(segments) != 3 || segments[0].Key != "vehicle" || segments[0].Label != "整车数据" || segments[1].Key != "fuel-cell" || segments[1].Label != "燃料电池" || segments[2].Key != "location" || segments[2].Label != "定位" {
|
||||
t.Fatalf("unexpected shared raw segments: %+v", segments)
|
||||
}
|
||||
}
|
||||
|
||||
type exactRawHistoryStore struct {
|
||||
*MockStore
|
||||
rows []HistoryDataRow
|
||||
}
|
||||
|
||||
func (s *exactRawHistoryStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
|
||||
if query.Category != "raw" {
|
||||
return s.MockStore.HistoryExportBatch(context.Background(), query, cursor, limit)
|
||||
}
|
||||
start := cursor.Offset
|
||||
if start > len(s.rows) {
|
||||
start = len(s.rows)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(s.rows) {
|
||||
end = len(s.rows)
|
||||
}
|
||||
result := append([]HistoryDataRow(nil), s.rows[start:end]...)
|
||||
cursor.Offset = end
|
||||
return result, cursor, nil
|
||||
}
|
||||
|
||||
func TestHistoryRawDataFiltersOnServerAndPaginatesBeyondOneThousand(t *testing.T) {
|
||||
const totalRows = 1205
|
||||
rows := make([]HistoryDataRow, 0, totalRows)
|
||||
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
for index := 0; index < totalRows; index++ {
|
||||
values := map[string]any{
|
||||
"frameType": "realtime",
|
||||
"rawSizeBytes": 430,
|
||||
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
|
||||
}
|
||||
if index%2 == 0 {
|
||||
values["gb32960.vehicle.speed_kmh"] = float64(index % 120)
|
||||
}
|
||||
at := start.Add(time.Duration(index) * time.Second).Format(time.RFC3339)
|
||||
rows = append(rows, HistoryDataRow{
|
||||
ID: "row-" + fmt.Sprintf("%04d", index), VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960",
|
||||
DeviceTime: at, ServerTime: at, Quality: "normal", Values: values,
|
||||
})
|
||||
}
|
||||
service := NewService(&exactRawHistoryStore{MockStore: NewMockStore(), rows: rows})
|
||||
response, err := service.HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"LB9A32A24R0LS1426"},
|
||||
"category": {"raw"},
|
||||
"rawSegment": {"fuel-cell"},
|
||||
"dateFrom": {"2026-07-01T00:00"},
|
||||
"dateTo": {"2026-07-02T00:00"},
|
||||
"limit": {"2"},
|
||||
"offset": {"1000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.SegmentCountsExact || response.AllTotal != totalRows || response.Total != totalRows || len(response.Rows) != 2 {
|
||||
t.Fatalf("unexpected exact raw page: exact=%v all=%d total=%d rows=%d", response.SegmentCountsExact, response.AllTotal, response.Total, len(response.Rows))
|
||||
}
|
||||
if response.Rows[0].ID != "row-0204" || response.Rows[1].ID != "row-0203" {
|
||||
t.Fatalf("deep raw page was truncated or unstable: %+v", response.Rows)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, segment := range response.Segments {
|
||||
counts[segment.Key] = segment.Count
|
||||
}
|
||||
if counts["fuel-cell"] != totalRows || counts["vehicle"] != 603 {
|
||||
t.Fatalf("segment counts should cover the complete time range: %+v", counts)
|
||||
}
|
||||
|
||||
vehicle, err := service.HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"LB9A32A24R0LS1426"},
|
||||
"category": {"raw"},
|
||||
"rawSegment": {"vehicle"},
|
||||
"dateFrom": {"2026-07-01T00:00"},
|
||||
"dateTo": {"2026-07-02T00:00"},
|
||||
"limit": {"2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vehicle.Total != 603 || len(vehicle.Rows) != 2 || vehicle.Rows[0].ID != "row-1204" || vehicle.Rows[1].ID != "row-1202" {
|
||||
t.Fatalf("server-side category filtering failed: total=%d rows=%+v", vehicle.Total, vehicle.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
definitions := []MetricDefinition{
|
||||
@@ -1120,3 +1958,63 @@ func BenchmarkLatestTelemetryHundredFrames(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleCoverageSupportsCombinedBusinessMultiSelectFilters(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
query := url.Values{
|
||||
"departmentIds": {"40001,40002"},
|
||||
"responsibleUserIds": {"50001,50002"},
|
||||
"customerIds": {"20001,20002"},
|
||||
"operationStatuses": {"运营中,已停运"},
|
||||
"limit": {"20"},
|
||||
}
|
||||
result, err := service.VehicleCoverage(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, item := range result.Items {
|
||||
got[item.VIN] = true
|
||||
}
|
||||
for _, vin := range []string{"LB9A32A24R0LS1426", "LNXNEGRR7SR318212", "LB9A32A24P0LS1230"} {
|
||||
if !got[vin] {
|
||||
t.Fatalf("combined multi-select omitted %s: %+v", vin, got)
|
||||
}
|
||||
}
|
||||
if got["LMRKH9AC2R1004087"] {
|
||||
t.Fatalf("responsible/status filters leaked unrelated vehicle: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleBusinessFiltersStayInsidePrincipalVINScope(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
UserType: "customer", Role: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426", "LB9A32A24P0LS1230"},
|
||||
})
|
||||
result, err := service.VehicleBusinessFilters(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Departments) != 1 || result.Departments[0].Value != "40001" || result.Departments[0].Count != 2 {
|
||||
t.Fatalf("department options escaped principal scope: %+v", result.Departments)
|
||||
}
|
||||
if len(result.ResponsibleUsers) != 1 || result.ResponsibleUsers[0].Value != "50001" {
|
||||
t.Fatalf("responsible options escaped principal scope: %+v", result.ResponsibleUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessFilterCannotWidenPrincipalVINScope(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
UserType: "customer", Role: "customer", VehicleVINs: []string{"LNXNEGRR7SR318212"},
|
||||
})
|
||||
result, err := service.VehicleCoverage(ctx, url.Values{
|
||||
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"}, "limit": {"20"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].VIN != "LNXNEGRR7SR318212" {
|
||||
t.Fatalf("client business filters widened principal scope: %+v", result.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
|
||||
if len(where) > 0 {
|
||||
countText += ` WHERE ` + strings.Join(where, " AND ")
|
||||
}
|
||||
if strings.TrimSpace(query["skipCount"]) == "1" {
|
||||
countText = ""
|
||||
}
|
||||
text += ` ORDER BY ts DESC, vin ASC, protocol ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
|
||||
return SQLQuery{Text: text, Args: args, CountText: countText}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user