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(ci.customer_name, r.customer_name, ''), COALESCE(CAST( CASE WHEN co.other_customer_id IS NOT NULL THEN co.other_business_department_id ELSE co.business_department_id END AS CHAR ), ''), COALESCE( CASE WHEN co.other_customer_id IS NOT NULL THEN co.other_business_department_name ELSE co.business_department_name END, r.business_dept, '' ), COALESCE(CAST( CASE WHEN co.other_customer_id IS NOT NULL THEN co.other_business_manager_id ELSE co.business_manager_id END AS CHAR ), ''), COALESCE( CASE WHEN co.other_customer_id IS NOT NULL THEN co.other_business_manager_name ELSE co.business_manager_name END, r.business_manager, '' ), 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, customerName string var departmentID, departmentName, responsibleUserID, responsibleUserName, 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, &customerName, &departmentID, &departmentName, &responsibleUserID, &responsibleUserName, &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, CustomerName: customerName, ContractCode: contractCode, ProjectName: projectName, DepartmentID: departmentID, DepartmentName: departmentName, ResponsibleUserID: responsibleUserID, ResponsibleUserName: responsibleUserName, 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 }