feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -0,0 +1,205 @@
package businessscope
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const SourceSystem = "oneos"
const (
ReasonVehicleMissing = "VEHICLE_MISSING"
ReasonVINMissing = "VIN_MISSING"
ReasonCustomerMissing = "CUSTOMER_MISSING"
ReasonCustomerProfileMissing = "CUSTOMER_PROFILE_MISSING"
ReasonContractMissing = "CONTRACT_MISSING"
ReasonContractProfileMissing = "CONTRACT_PROFILE_MISSING"
ReasonContractCustomerMissing = "CONTRACT_CUSTOMER_MISSING"
ReasonCustomerMismatch = "CUSTOMER_MISMATCH"
ReasonDuplicateVehicle = "DUPLICATE_VEHICLE_SCOPE"
ReasonDuplicateVIN = "DUPLICATE_VIN_SCOPE"
)
type Candidate struct {
RowNumber int
VehicleID int64
VehiclePresent bool
VIN string
PlateNumber string
CustomerID int64
CustomerPresent bool
CustomerProfileExists bool
ContractID int64
ContractPresent bool
ContractProfileExists bool
EffectiveCustomerID int64
EffectiveCustomerSet bool
ContractCode string
ProjectName string
OperationStatus string
ScopeStartAt time.Time
SourceUpdatedAt *time.Time
}
type ScopeItem struct {
VehicleID int64
VIN string
PlateNumber string
CustomerID int64
ContractID int64
ContractCode string
ProjectName string
OperationStatus string
ScopeStartAt time.Time
SourceUpdatedAt *time.Time
}
type Rejection struct {
RowNumber int
VehicleID *int64
VIN string
CustomerID *int64
ContractID *int64
ReasonCode string
}
type Snapshot struct {
RunID string
SourceVersion string
Checksum string
GeneratedAt time.Time
Candidates int
Items []ScopeItem
Rejections []Rejection
}
func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, error) {
generatedAt = generatedAt.UTC()
reasons := make([]string, len(candidates))
vehicleRows := map[int64][]int{}
vinRows := map[string][]int{}
for i, candidate := range candidates {
candidate.VIN = normalizeVIN(candidate.VIN)
candidates[i] = candidate
reasons[i] = validateCandidate(candidate)
if reasons[i] == "" {
vehicleRows[candidate.VehicleID] = append(vehicleRows[candidate.VehicleID], i)
vinRows[candidate.VIN] = append(vinRows[candidate.VIN], i)
}
}
for _, indexes := range vehicleRows {
if len(indexes) > 1 {
for _, index := range indexes {
reasons[index] = ReasonDuplicateVehicle
}
}
}
for _, indexes := range vinRows {
if len(indexes) > 1 {
for _, index := range indexes {
if reasons[index] == "" {
reasons[index] = ReasonDuplicateVIN
}
}
}
}
items := make([]ScopeItem, 0, len(candidates))
rejections := make([]Rejection, 0)
for index, candidate := range candidates {
if reasons[index] != "" {
rejections = append(rejections, rejectionFor(candidate, reasons[index]))
continue
}
items = append(items, ScopeItem{
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
CustomerID: candidate.CustomerID, ContractID: candidate.ContractID,
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
SourceUpdatedAt: candidate.SourceUpdatedAt,
})
}
sort.Slice(items, func(i, j int) bool {
if items[i].CustomerID != items[j].CustomerID {
return items[i].CustomerID < items[j].CustomerID
}
return items[i].VIN < items[j].VIN
})
sort.Slice(rejections, func(i, j int) bool { return rejections[i].RowNumber < rejections[j].RowNumber })
canonical, err := json.Marshal(items)
if err != nil {
return Snapshot{}, fmt.Errorf("marshal canonical scope: %w", err)
}
sum := sha256.Sum256(canonical)
checksum := hex.EncodeToString(sum[:])
runID, err := randomHex(16)
if err != nil {
return Snapshot{}, err
}
return Snapshot{
RunID: runID, SourceVersion: "oneos-v1:" + checksum, Checksum: checksum,
GeneratedAt: generatedAt, Candidates: len(candidates), Items: items, Rejections: rejections,
}, nil
}
func validateCandidate(candidate Candidate) string {
if !candidate.VehiclePresent || candidate.VehicleID <= 0 {
return ReasonVehicleMissing
}
if normalizeVIN(candidate.VIN) == "" {
return ReasonVINMissing
}
if !candidate.CustomerPresent || candidate.CustomerID <= 0 {
return ReasonCustomerMissing
}
if !candidate.CustomerProfileExists {
return ReasonCustomerProfileMissing
}
if !candidate.ContractPresent || candidate.ContractID <= 0 {
return ReasonContractMissing
}
if !candidate.ContractProfileExists {
return ReasonContractProfileMissing
}
if !candidate.EffectiveCustomerSet || candidate.EffectiveCustomerID <= 0 {
return ReasonContractCustomerMissing
}
if candidate.CustomerID != candidate.EffectiveCustomerID {
return ReasonCustomerMismatch
}
if candidate.ScopeStartAt.IsZero() {
return ReasonVehicleMissing
}
return ""
}
func rejectionFor(candidate Candidate, reason string) Rejection {
rejection := Rejection{RowNumber: candidate.RowNumber, VIN: normalizeVIN(candidate.VIN), ReasonCode: reason}
if candidate.VehicleID > 0 {
rejection.VehicleID = int64Pointer(candidate.VehicleID)
}
if candidate.CustomerPresent && candidate.CustomerID > 0 {
rejection.CustomerID = int64Pointer(candidate.CustomerID)
}
if candidate.ContractPresent && candidate.ContractID > 0 {
rejection.ContractID = int64Pointer(candidate.ContractID)
}
return rejection
}
func normalizeVIN(value string) string { return strings.ToUpper(strings.TrimSpace(value)) }
func int64Pointer(value int64) *int64 { return &value }
func randomHex(bytes int) (string, error) {
buffer := make([]byte, bytes)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate run id: %w", err)
}
return hex.EncodeToString(buffer), nil
}

View File

@@ -0,0 +1,93 @@
package businessscope
import (
"testing"
"time"
)
func validCandidate(row int, vehicleID, customerID int64, vin string) Candidate {
return Candidate{
RowNumber: row, VehicleID: vehicleID, VehiclePresent: true, VIN: vin, PlateNumber: "沪A00001",
CustomerID: customerID, CustomerPresent: true, CustomerProfileExists: true,
ContractID: vehicleID + 1000, ContractPresent: true, ContractProfileExists: true,
EffectiveCustomerID: customerID, EffectiveCustomerSet: true,
ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
ScopeStartAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.FixedZone("CST", 8*3600)),
}
}
func TestBuildSnapshotClassifiesAndNormalizes(t *testing.T) {
candidates := []Candidate{
validCandidate(1, 10, 100, " lvin0001 "),
func() Candidate {
value := validCandidate(2, 11, 101, "LVIN0002")
value.CustomerProfileExists = false
return value
}(),
func() Candidate {
value := validCandidate(3, 12, 102, "LVIN0003")
value.EffectiveCustomerID = 999
return value
}(),
}
snapshot, err := BuildSnapshot(candidates, time.Now())
if err != nil {
t.Fatal(err)
}
if snapshot.Candidates != 3 || len(snapshot.Items) != 1 || len(snapshot.Rejections) != 2 {
t.Fatalf("unexpected accounting: %#v", snapshot)
}
if snapshot.Items[0].VIN != "LVIN0001" {
t.Fatalf("VIN not normalized: %q", snapshot.Items[0].VIN)
}
if snapshot.Rejections[0].ReasonCode != ReasonCustomerProfileMissing || snapshot.Rejections[1].ReasonCode != ReasonCustomerMismatch {
t.Fatalf("unexpected reasons: %#v", snapshot.Rejections)
}
}
func TestBuildSnapshotRejectsEveryDuplicateVehicle(t *testing.T) {
snapshot, err := BuildSnapshot([]Candidate{
validCandidate(1, 10, 100, "LVIN0001"),
validCandidate(2, 10, 100, "LVIN0001"),
}, time.Now())
if err != nil {
t.Fatal(err)
}
if len(snapshot.Items) != 0 || len(snapshot.Rejections) != 2 {
t.Fatalf("duplicates must all be quarantined: %#v", snapshot)
}
for _, rejection := range snapshot.Rejections {
if rejection.ReasonCode != ReasonDuplicateVehicle {
t.Fatalf("unexpected duplicate reason: %#v", rejection)
}
}
}
func TestSnapshotVersionIsContentAddressed(t *testing.T) {
first := []Candidate{validCandidate(1, 10, 100, "LVIN0001"), validCandidate(2, 11, 101, "LVIN0002")}
second := []Candidate{first[1], first[0]}
a, err := BuildSnapshot(first, time.Now())
if err != nil {
t.Fatal(err)
}
b, err := BuildSnapshot(second, time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if a.SourceVersion != b.SourceVersion || a.Checksum != b.Checksum {
t.Fatalf("same content produced different versions: %s %s", a.SourceVersion, b.SourceVersion)
}
}
func TestValidateSnapshotUsesCountAndRatioGates(t *testing.T) {
snapshot := Snapshot{Candidates: 639, Items: make([]ScopeItem, 613), Rejections: make([]Rejection, 26)}
if err := ValidateSnapshot(snapshot, 100, 0.10); err != nil {
t.Fatalf("production baseline should pass: %v", err)
}
if err := ValidateSnapshot(snapshot, 20, 0.10); err == nil {
t.Fatal("rejection count limit should fail")
}
if err := ValidateSnapshot(snapshot, 100, 0.01); err == nil {
t.Fatal("rejection ratio limit should fail")
}
}

View File

@@ -0,0 +1,105 @@
package businessscope
import (
"context"
"database/sql"
"fmt"
"time"
)
type PublishResult struct {
Changed bool
ActiveVersion string
}
func ValidateSnapshot(snapshot Snapshot, maxRejected int, maxRejectRatio float64) error {
if snapshot.Candidates <= 0 {
return fmt.Errorf("OneOS scope snapshot contains no candidate rows")
}
if len(snapshot.Items) <= 0 {
return fmt.Errorf("OneOS scope snapshot contains no publishable rows")
}
if snapshot.Candidates != len(snapshot.Items)+len(snapshot.Rejections) {
return fmt.Errorf("scope accounting mismatch: candidates=%d accepted=%d rejected=%d", snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections))
}
if maxRejected >= 0 && len(snapshot.Rejections) > maxRejected {
return fmt.Errorf("scope rejected rows exceed limit: rejected=%d limit=%d", len(snapshot.Rejections), maxRejected)
}
ratio := float64(len(snapshot.Rejections)) / float64(snapshot.Candidates)
if maxRejectRatio >= 0 && ratio > maxRejectRatio {
return fmt.Errorf("scope rejected ratio exceeds limit: ratio=%.6f limit=%.6f", ratio, maxRejectRatio)
}
return nil
}
func Publish(ctx context.Context, db *sql.DB, snapshot Snapshot) (PublishResult, error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return PublishResult{}, fmt.Errorf("begin scope publish: %w", err)
}
defer tx.Rollback()
var activeVersion sql.NullString
if err := tx.QueryRowContext(ctx, `SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`).Scan(&activeVersion); err != nil {
return PublishResult{}, fmt.Errorf("lock business scope state; apply migration 012_business_scope_projection.sql: %w", err)
}
status := "published"
changed := !activeVersion.Valid || activeVersion.String != snapshot.SourceVersion
if !changed {
status = "unchanged"
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, `INSERT INTO business_scope_sync_run(
run_id,source_system,source_version,status,candidate_count,accepted_count,rejected_count,source_checksum,started_at,finished_at
) VALUES(?,?,?,?,?,?,?,?,?,?)`, snapshot.RunID, SourceSystem, snapshot.SourceVersion, status, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.Checksum, snapshot.GeneratedAt, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope sync run: %w", err)
}
if changed {
// A source version can become active again after the source data reverts.
// Rebuild that inactive version while holding the state lock so publishing
// remains atomic and cannot fail on its existing primary keys.
if _, err := tx.ExecContext(ctx, `DELETE FROM business_customer_vehicle_scope WHERE source_version=?`, snapshot.SourceVersion); err != nil {
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
}
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope(
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
}
defer statement.Close()
for _, item := range snapshot.Items {
var contractID any
if item.ContractID > 0 {
contractID = item.ContractID
}
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID,
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
}
}
}
if len(snapshot.Rejections) > 0 {
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection(
run_id,row_number,vehicle_id,vin,customer_id,contract_id,reason_code,created_at
) VALUES(?,?,?,?,?,?,?,?)`)
if err != nil {
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
}
defer statement.Close()
for _, rejected := range snapshot.Rejections {
if _, err := statement.ExecContext(ctx, snapshot.RunID, rejected.RowNumber, rejected.VehicleID, rejected.VIN,
rejected.CustomerID, rejected.ContractID, rejected.ReasonCode, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope rejection: %w", err)
}
}
}
if _, err := tx.ExecContext(ctx, `UPDATE business_scope_state SET
active_version=?,source_checksum=?,candidate_count=?,accepted_count=?,rejected_count=?,generated_at=?,published_at=?,last_success_at=?
WHERE id=1`, snapshot.SourceVersion, snapshot.Checksum, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.GeneratedAt, now, now); err != nil {
return PublishResult{}, fmt.Errorf("activate business scope snapshot: %w", err)
}
if err := tx.Commit(); err != nil {
return PublishResult{}, fmt.Errorf("commit business scope snapshot: %w", err)
}
return PublishResult{Changed: changed, ActiveVersion: snapshot.SourceVersion}, nil
}

View File

@@ -0,0 +1,123 @@
package businessscope
import (
"context"
"errors"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope(
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`
func publishSnapshot(version string) Snapshot {
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
return Snapshot{
RunID: "0123456789abcdef0123456789abcdef", SourceVersion: version,
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
GeneratedAt: generatedAt, Candidates: 1,
Items: []ScopeItem{{
VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100,
ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
ScopeStartAt: generatedAt.Add(-time.Hour),
}},
}
}
func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
snapshot := publishSnapshot("oneos-v1:new")
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current"))
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).
WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "published", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)).
WithArgs(snapshot.SourceVersion).WillReturnResult(sqlmock.NewResult(0, 1))
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
item := snapshot.Items[0]
prepared.ExpectExec().WithArgs(
snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID,
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE business_scope_state SET`).
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
result, err := Publish(context.Background(), db, snapshot)
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.ActiveVersion != snapshot.SourceVersion {
t.Fatalf("unexpected publish result: %#v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPublishUnchangedDoesNotRewriteScopeRows(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
snapshot := publishSnapshot("oneos-v1:same")
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow(snapshot.SourceVersion))
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).
WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "unchanged", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE business_scope_state SET`).
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
result, err := Publish(context.Background(), db, snapshot)
if err != nil {
t.Fatal(err)
}
if result.Changed {
t.Fatalf("unchanged snapshot reported as changed: %#v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPublishRollsBackWhenVersionCannotBeCleared(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
snapshot := publishSnapshot("oneos-v1:new")
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current"))
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)).
WithArgs(snapshot.SourceVersion).WillReturnError(errors.New("delete failed"))
mock.ExpectRollback()
if _, err := Publish(context.Background(), db, snapshot); err == nil {
t.Fatal("publish should fail when stale version cannot be rebuilt")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,200 @@
package businessscope
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// candidateQuery deliberately derives the active lease lifecycle from delivery and
// return task facts. vehicle_lease_order_record remains an independent customer
// ownership check, but its last_return_time is not authoritative: saving a return
// draft currently updates that aggregate field before the return is completed.
const candidateQuery = `SELECT
dv.vehicle_id,
v.id,
COALESCE(v.vin, ''),
COALESCE(v.plate_number, dv.plate_number, r.plate_number, ''),
r.customer_id,
ci.id,
dv.contract_id,
co.id,
COALESCE(co.other_customer_id, co.customer_id),
COALESCE(co.contract_code, r.contract_code, ''),
COALESCE(co.project_name, r.project_name, ''),
COALESCE(vs.operation_status, ''),
dv.delivery_time,
dv.update_time
FROM delivery_vehicle dv
LEFT JOIN vehicle_info v ON v.id = dv.vehicle_id AND v.del_flag = '0'
LEFT JOIN vehicle_lease_order_record r ON r.vehicle_id = dv.vehicle_id AND r.del_flag = '0'
LEFT JOIN customer_info ci ON ci.id = r.customer_id AND ci.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info co ON co.id = dv.contract_id AND co.del_flag = '0'
LEFT JOIN vehicle_status vs ON vs.vehicle_id = dv.vehicle_id AND vs.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.delivery_status IN (2, 3)
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM delivery_vehicle newer
WHERE newer.del_flag = '0'
AND newer.vehicle_id = dv.vehicle_id
AND newer.vehicle_id IS NOT NULL
AND (
COALESCE(newer.delivery_time, '1000-01-01') > COALESCE(dv.delivery_time, '1000-01-01')
OR (
COALESCE(newer.delivery_time, '1000-01-01') = COALESCE(dv.delivery_time, '1000-01-01')
AND newer.id > dv.id
)
)
)
AND NOT EXISTS (
SELECT 1
FROM return_vehicle_task rt
WHERE rt.delivery_vehicle_id = dv.id
AND rt.del_flag = '0'
AND rt.status IN (2, 3, 5)
)
ORDER BY dv.vehicle_id, dv.id, r.id`
func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
connection, err := db.Conn(ctx)
if err != nil {
return nil, fmt.Errorf("acquire OneOS connection: %w", err)
}
defer connection.Close()
if err := VerifyReadOnlyGrants(ctx, connection); err != nil {
return nil, err
}
if _, err := connection.ExecContext(ctx, `SET SESSION TRANSACTION READ ONLY`); err != nil {
return nil, fmt.Errorf("force OneOS session read only: %w", err)
}
if _, err := connection.ExecContext(ctx, `SET SESSION MAX_EXECUTION_TIME = 10000`); err != nil {
return nil, fmt.Errorf("set OneOS query timeout: %w", err)
}
tx, err := connection.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if err != nil {
return nil, fmt.Errorf("begin OneOS read-only snapshot: %w", err)
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, candidateQuery)
if err != nil {
return nil, fmt.Errorf("query OneOS customer vehicle candidates: %w", err)
}
defer rows.Close()
candidates := make([]Candidate, 0, 1024)
for rows.Next() {
var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64
var contractID, contractProfileID, effectiveCustomerID sql.NullInt64
var vin, plate, contractCode, projectName, operationStatus string
var scopeStart time.Time
var updatedAt sql.NullTime
if err := rows.Scan(
&vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID,
&contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName,
&operationStatus, &scopeStart, &updatedAt,
); err != nil {
return nil, fmt.Errorf("scan OneOS scope candidate: %w", err)
}
candidate := Candidate{
RowNumber: len(candidates) + 1,
VehicleID: vehicleID.Int64, VehiclePresent: vehicleID.Valid && vehicleProfileID.Valid,
VIN: vin, PlateNumber: plate,
CustomerID: customerID.Int64, CustomerPresent: customerID.Valid,
CustomerProfileExists: customerProfileID.Valid,
ContractID: contractID.Int64, ContractPresent: contractID.Valid,
ContractProfileExists: contractProfileID.Valid,
EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid,
ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus,
ScopeStartAt: scopeStart,
}
if updatedAt.Valid {
value := updatedAt.Time
candidate.SourceUpdatedAt = &value
}
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate OneOS scope candidates: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("finish OneOS read-only snapshot: %w", err)
}
return candidates, nil
}
func VerifyReadOnlyGrants(ctx context.Context, connection *sql.Conn) error {
rows, err := connection.QueryContext(ctx, `SHOW GRANTS FOR CURRENT_USER`)
if err != nil {
return fmt.Errorf("inspect OneOS database grants: %w", err)
}
defer rows.Close()
grants := make([]string, 0)
for rows.Next() {
var grant string
if err := rows.Scan(&grant); err != nil {
return fmt.Errorf("scan OneOS database grant: %w", err)
}
grants = append(grants, grant)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate OneOS database grants: %w", err)
}
if err := ValidateReadOnlyGrants(grants); err != nil {
return fmt.Errorf("OneOS database account is not read-only: %w", err)
}
return nil
}
func ValidateReadOnlyGrants(grants []string) error {
if len(grants) == 0 {
return fmt.Errorf("SHOW GRANTS returned no rows")
}
allowedSelectScopes := map[string]bool{
"LN_ASSET_MANAGEMENT.VEHICLE_LEASE_ORDER_RECORD": true,
"LN_ASSET_MANAGEMENT.DELIVERY_VEHICLE": true,
"LN_ASSET_MANAGEMENT.RETURN_VEHICLE_TASK": true,
"LN_ASSET_MANAGEMENT.VEHICLE_INFO": true,
"LN_ASSET_MANAGEMENT.CUSTOMER_INFO": true,
"LN_ASSET_MANAGEMENT.VEHICLE_LEASE_CONTRACT_INFO": true,
"LN_ASSET_MANAGEMENT.VEHICLE_STATUS": true,
}
for _, raw := range grants {
grant := strings.ToUpper(strings.TrimSpace(raw))
if strings.HasPrefix(grant, "SET DEFAULT ROLE ") {
return fmt.Errorf("role-based grants are not accepted; grant SELECT directly to the sync account")
}
if !strings.HasPrefix(grant, "GRANT ") {
return fmt.Errorf("unsupported grant statement")
}
onIndex := strings.Index(grant, " ON ")
if onIndex < 0 {
return fmt.Errorf("unsupported role or dynamic grant")
}
privileges := strings.TrimSpace(strings.TrimPrefix(grant[:onIndex], "GRANT "))
toIndex := strings.Index(grant[onIndex+4:], " TO ")
if toIndex < 0 {
return fmt.Errorf("unsupported grant scope")
}
scope := strings.ReplaceAll(strings.TrimSpace(grant[onIndex+4:onIndex+4+toIndex]), "`", "")
for _, privilege := range strings.Split(privileges, ",") {
privilege = strings.TrimSpace(privilege)
switch privilege {
case "USAGE":
if scope != "*.*" {
return fmt.Errorf("USAGE has unsupported scope %q", scope)
}
case "SELECT":
if !allowedSelectScopes[scope] {
return fmt.Errorf("SELECT scope %q is not required by the sync query", scope)
}
default:
return fmt.Errorf("disallowed privilege %q", privilege)
}
}
}
return nil
}

View File

@@ -0,0 +1,60 @@
package businessscope
import (
"strings"
"testing"
)
func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t *testing.T) {
assertions := []string{
"FROM delivery_vehicle dv",
"dv.delivery_status IN (2, 3)",
"FROM return_vehicle_task rt",
"rt.status IN (2, 3, 5)",
"FROM delivery_vehicle newer",
"LEFT JOIN vehicle_lease_order_record r",
}
for _, expected := range assertions {
if !strings.Contains(candidateQuery, expected) {
t.Fatalf("candidate query missing %q", expected)
}
}
if strings.Contains(candidateQuery, "r.last_return_time") {
t.Fatal("candidate query must not trust aggregate last_return_time written by return drafts")
}
}
func TestValidateReadOnlyGrantsAllowsOnlyRequiredTableSelects(t *testing.T) {
grants := []string{
"GRANT USAGE ON *.* TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`vehicle_lease_order_record` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`delivery_vehicle` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`return_vehicle_task` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`customer_info` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`vehicle_lease_contract_info` TO `reader`@`%`",
"GRANT SELECT ON `ln_asset_management`.`vehicle_status` TO `reader`@`%`",
}
if err := ValidateReadOnlyGrants(grants); err != nil {
t.Fatalf("read-only grants rejected: %v", err)
}
}
func TestValidateReadOnlyGrantsRejectsBroadOrWritePrivileges(t *testing.T) {
cases := [][]string{
{"GRANT ALL PRIVILEGES ON `ln_asset_management`.* TO `reader`@`%`"},
{"GRANT SELECT, UPDATE ON `ln_asset_management`.* TO `reader`@`%`"},
{"GRANT SELECT ON *.* TO `reader`@`%`"},
{"GRANT SELECT ON `ln_asset_management`.* TO `reader`@`%`"},
{"GRANT SELECT ON `ry-cloud`.`sys_user` TO `reader`@`%`"},
{"GRANT SHOW VIEW ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`"},
{"GRANT PROCESS, REPLICATION CLIENT ON *.* TO `reader`@`%`"},
{"GRANT `scope_reader_role`@`%` TO `reader`@`%`"},
{"SET DEFAULT ROLE `scope_reader_role`@`%` TO `reader`@`%`"},
}
for _, grants := range cases {
if err := ValidateReadOnlyGrants(grants); err == nil {
t.Fatalf("unsafe grants accepted: %#v", grants)
}
}
}