feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
@@ -118,6 +118,8 @@ WAL 在分组 `fsync` 成功后才向协议处理线程返回接受成功,网
|
||||
|
||||
GB32960 登录密码在鉴权完成后会从 `parsed_fields` 删除,只保留 `password_present`;协议原始帧仍可能包含凭据,因此 RAW 查询接口和 TDengine `raw_hex` 必须按敏感数据控制访问。JT808 的 `0x0102` 会先匹配配置的公共鉴权码,再按规范化手机号匹配 `jt808_registration.auth_token` 的只读内存快照,全程不查询 MySQL;指标 `source=device` 表示命中终端历史鉴权码。强制模式拒绝的鉴权帧不会覆盖该可信鉴权码和最近鉴权成功时间。JT808 的 `enforce` 不会拒绝未发注册/认证、直接上报 `0x0200` 的兼容平台;这类来源继续通过身份缺口指标治理。
|
||||
|
||||
GB32960 的 `Feichi` 平台账号使用独立的 `0x80` 处理:按同一 TCP 连接的平台登录账号精确匹配 `Feichi`,以两字节大端声明长度读取整个自定义块,不套用广东演示扩展的固定字段含义。完整帧继续保存在 RAW `raw_hex`;各块保存为 `gb32960.feichi_custom_80.blocks.block_1.*`、`block_2.*` 等字段,包含 `declared_length`、`actual_length`、`length_hex`、`payload_hex` 和包含类型字节的 `raw_hex`。声明长度超出剩余报文或长度字节不完整时保留所有已收到字节,标记 `PARTIAL`,并在块内保存 `parse_error=truncated_payload/truncated_length`。`Hyundai`、`YueJin`、未登录及其他账号继续使用原解析;`Feichi` 的其他数据单元保持原解析。外部直推和本机 API 桥接都使用此账号,因此按账号生效,不按来源 IP 选择。
|
||||
|
||||
## 标准发布流程
|
||||
|
||||
Go 原生服务发布必须使用 Linux amd64 构建产物,不要直接上传本机默认 `go build` 结果。仓库提供统一脚本:
|
||||
|
||||
@@ -66,12 +66,13 @@ func main() {
|
||||
|
||||
protocols := []gateway.TCPProtocol{
|
||||
{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Authenticate: gb32960Authenticator,
|
||||
Respond: gb32960.AutoResponse,
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
ParseForPlatform: gb32960.ParseFrameForPlatform,
|
||||
Authenticate: gb32960Authenticator,
|
||||
Respond: gb32960.AutoResponse,
|
||||
},
|
||||
{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,10 +29,13 @@ type change struct {
|
||||
Total string `json:"latest_total_mileage_km"`
|
||||
LastTime string `json:"latest_event_time"`
|
||||
Daily string `json:"daily_mileage_km"`
|
||||
Status string `json:"quality_status"`
|
||||
Selected int `json:"is_selected"`
|
||||
} `json:"before"`
|
||||
Baseline struct {
|
||||
Total string `json:"total"`
|
||||
Time string `json:"time"`
|
||||
Source string `json:"source_key"`
|
||||
Total string `json:"total"`
|
||||
Time string `json:"time"`
|
||||
} `json:"baseline"`
|
||||
Daily string `json:"daily"`
|
||||
Status string `json:"status"`
|
||||
@@ -42,6 +46,7 @@ func main() {
|
||||
input := flag.String("input", "", "reviewed JSON plan")
|
||||
apply := flag.Bool("apply", false, "apply plan and projections in one transaction")
|
||||
manifest := flag.String("backup-manifest", "", "verified backup manifest required for apply")
|
||||
selectedOnly := flag.Bool("selected-projections-only", false, "preserve final results when only unselected candidates change")
|
||||
flag.Parse()
|
||||
data, err := os.ReadFile(*input)
|
||||
must(err)
|
||||
@@ -67,24 +72,44 @@ func main() {
|
||||
affected := map[string]change{}
|
||||
for _, c := range changes {
|
||||
b := c.Before
|
||||
total, stamp, found, err := stats.LookupLatestSourceBaselineBefore(ctx, tx, b.VIN, b.Date, envelope.Protocol(b.Protocol), b.Source)
|
||||
baselineSource := b.Source
|
||||
if c.Baseline.Source != "" && c.Baseline.Source != b.Source {
|
||||
// Historical IP keys and current platform keys may describe the
|
||||
// same terminal. Verify that identity against the registry before
|
||||
// accepting an explicitly reviewed alias boundary.
|
||||
currentCanonical, err := canonicalRepairSource(ctx, tx, b.Protocol, b.Source)
|
||||
must(err)
|
||||
baselineCanonical, err := canonicalRepairSource(ctx, tx, b.Protocol, c.Baseline.Source)
|
||||
must(err)
|
||||
if currentCanonical != baselineCanonical {
|
||||
panic("repair cannot mix independent source counters")
|
||||
}
|
||||
baselineSource = c.Baseline.Source
|
||||
}
|
||||
total, stamp, found, err := stats.LookupLatestSourceBaselineBefore(ctx, tx, b.VIN, b.Date, envelope.Protocol(b.Protocol), baselineSource)
|
||||
must(err)
|
||||
expected, err := strconv.ParseFloat(c.Baseline.Total, 64)
|
||||
must(err)
|
||||
if !found || math.Abs(total-expected) > 0.0001 || stamp.Format("2006-01-02 15:04:05") != c.Baseline.Time {
|
||||
panic(fmt.Sprintf("baseline changed: %s %s", b.VIN, b.Date))
|
||||
}
|
||||
oldStatus := b.Status
|
||||
if oldStatus == "" {
|
||||
oldStatus = stats.QualityOK
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=?,first_event_time=?,daily_mileage_km=?,quality_status=?,quality_reason=?
|
||||
WHERE vin=? AND stat_date=? AND protocol=? AND source_key=?
|
||||
AND first_total_mileage_km <=> ? AND first_event_time <=> ? AND latest_total_mileage_km=?
|
||||
AND latest_event_time=? AND daily_mileage_km=? AND quality_status='OK'`, c.Baseline.Total, c.Baseline.Time, c.Daily, c.Status, c.Reason, b.VIN, b.Date, b.Protocol, b.Source, b.First, b.FirstTime, b.Total, b.LastTime, b.Daily)
|
||||
AND latest_event_time=? AND daily_mileage_km=? AND quality_status=?`, c.Baseline.Total, c.Baseline.Time, c.Daily, c.Status, c.Reason, b.VIN, b.Date, b.Protocol, b.Source, b.First, b.FirstTime, b.Total, b.LastTime, b.Daily, oldStatus)
|
||||
must(err)
|
||||
n, err := result.RowsAffected()
|
||||
must(err)
|
||||
if n != 1 {
|
||||
panic(fmt.Sprintf("concurrent change: %s %s %s", b.VIN, b.Date, b.Source))
|
||||
}
|
||||
affected[b.Date+"|"+b.VIN+"|"+b.Protocol] = c
|
||||
if !*selectedOnly || b.Selected == 1 {
|
||||
affected[b.Date+"|"+b.VIN+"|"+b.Protocol] = c
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(affected))
|
||||
for key := range affected {
|
||||
@@ -98,6 +123,43 @@ func main() {
|
||||
must(tx.Commit())
|
||||
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"updatedSources": len(changes), "reprojectedDays": len(keys), "committed": true}))
|
||||
}
|
||||
|
||||
func canonicalRepairSource(ctx context.Context, db stats.Queryer, protocol, key string) (string, error) {
|
||||
if protocol != "JT808" {
|
||||
return "", fmt.Errorf("source alias repair is only supported for JT808")
|
||||
}
|
||||
if strings.Contains(key, "@PLATFORM:") || strings.HasSuffix(key, "@DIRECT") {
|
||||
return key, nil
|
||||
}
|
||||
at := strings.LastIndex(key, "@")
|
||||
if at < 0 || !strings.HasPrefix(key, protocol+":") {
|
||||
return "", fmt.Errorf("unsupported legacy source key")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT COALESCE(source_code,''),source_kind
|
||||
FROM vehicle_data_source WHERE protocol=? AND source_ip=?`, protocol, key[at+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return "", fmt.Errorf("source registry is missing the legacy IP")
|
||||
}
|
||||
var code, kind string
|
||||
if err := rows.Scan(&code, &kind); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind == "PLATFORM" && strings.TrimSpace(code) != "" {
|
||||
return key[:at] + "@PLATFORM:" + strings.TrimSpace(code), nil
|
||||
}
|
||||
if kind == "DIRECT" {
|
||||
return key[:at] + "@DIRECT", nil
|
||||
}
|
||||
return "", fmt.Errorf("legacy source has no verified platform or direct-terminal identity")
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestCanonicalRepairSourceRequiresVerifiedRegistryIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, key, code, kind, want string
|
||||
missing, wantError bool
|
||||
}{
|
||||
{name: "platform", key: "JT808:41456413939@117.132.196.192", code: "guangan_beidou", kind: "PLATFORM", want: "JT808:41456413939@PLATFORM:guangan_beidou"},
|
||||
{name: "direct", key: "JT808:41456413939@117.132.196.192", kind: "DIRECT", want: "JT808:41456413939@DIRECT"},
|
||||
{name: "unknown", key: "JT808:41456413939@117.132.196.192", kind: "UNKNOWN", wantError: true},
|
||||
{name: "missing", key: "JT808:41456413939@117.132.196.192", missing: true, wantError: true},
|
||||
{name: "platform-without-code", key: "JT808:41456413939@117.132.196.192", kind: "PLATFORM", wantError: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows := sqlmock.NewRows([]string{"code", "kind"})
|
||||
if !tc.missing {
|
||||
rows.AddRow(tc.code, tc.kind)
|
||||
}
|
||||
mock.ExpectQuery("SELECT COALESCE").WithArgs("JT808", "117.132.196.192").WillReturnRows(rows)
|
||||
got, err := canonicalRepairSource(context.Background(), db, "JT808", tc.key)
|
||||
if (err != nil) != tc.wantError || got != tc.want {
|
||||
t.Fatalf("got=%q err=%v want=%q wantError=%v", got, err, tc.want, tc.wantError)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalRepairSourceKeepsIndependentCounters(t *testing.T) {
|
||||
for _, key := range []string{"JT808:terminal-a@PLATFORM:g7s", "JT808:terminal-b@PLATFORM:g7s", "JT808:terminal-a@PLATFORM:dongfang_beidou"} {
|
||||
got, err := canonicalRepairSource(context.Background(), nil, "JT808", key)
|
||||
if err != nil || got != key {
|
||||
t.Fatalf("independent identity changed: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
if _, err := canonicalRepairSource(context.Background(), nil, "GB32960", "GB32960:vin@PLATFORM:g7s"); err == nil {
|
||||
t.Fatal("alias repair must not silently change other protocol rules")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Apply reviewed event-time boundary corrections with a verified backup.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
VIN string `json:"vin"`
|
||||
Date string `json:"stat_date"`
|
||||
Protocol string `json:"protocol"`
|
||||
Source string `json:"source_key"`
|
||||
First string `json:"first_total_mileage_km"`
|
||||
Last string `json:"latest_total_mileage_km"`
|
||||
FirstTime string `json:"first_event_time"`
|
||||
LastTime string `json:"latest_event_time"`
|
||||
Daily string `json:"daily_mileage_km"`
|
||||
Status string `json:"quality_status"`
|
||||
Reason string `json:"quality_reason"`
|
||||
Count int64 `json:"sample_count"`
|
||||
}
|
||||
type change struct {
|
||||
Before row `json:"before"`
|
||||
After row `json:"after"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
input := flag.String("input", "", "reviewed event boundary plan")
|
||||
manifest := flag.String("backup-manifest", "", "verified backup manifest")
|
||||
apply := flag.Bool("apply", false, "apply changes atomically")
|
||||
flag.Parse()
|
||||
data, err := os.ReadFile(*input)
|
||||
must(err)
|
||||
var plan []change
|
||||
must(json.Unmarshal(data, &plan))
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
for _, c := range plan {
|
||||
must(validateChange(c, loc))
|
||||
}
|
||||
if !*apply {
|
||||
fmt.Printf("planned=%d dry_run=true\n", len(plan))
|
||||
return
|
||||
}
|
||||
var backup struct {
|
||||
File string `json:"backup_file"`
|
||||
SHA string `json:"backup_sha256"`
|
||||
PlanSHA string `json:"plan_sha256"`
|
||||
Verified bool `json:"gzip_and_json_verified"`
|
||||
}
|
||||
b, err := os.ReadFile(*manifest)
|
||||
must(err)
|
||||
must(json.Unmarshal(b, &backup))
|
||||
digest := sha256.Sum256(data)
|
||||
if !backup.Verified || hex.EncodeToString(digest[:]) != backup.PlanSHA {
|
||||
panic("reviewed plan is not the backed-up plan")
|
||||
}
|
||||
compressed, err := os.ReadFile(backup.File)
|
||||
must(err)
|
||||
digest = sha256.Sum256(compressed)
|
||||
if hex.EncodeToString(digest[:]) != backup.SHA {
|
||||
panic("backup checksum mismatch")
|
||||
}
|
||||
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||
must(err)
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
must(err)
|
||||
defer tx.Rollback()
|
||||
sort.Slice(plan, func(i, j int) bool {
|
||||
a, b := plan[i].Before, plan[j].Before
|
||||
return a.Date+a.VIN+a.Source < b.Date+b.VIN+b.Source
|
||||
})
|
||||
targets := map[string]row{}
|
||||
for _, c := range plan {
|
||||
b, a := c.Before, c.After
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=?,first_event_time=?,latest_total_mileage_km=?,latest_event_time=?,daily_mileage_km=?,quality_status=?,quality_reason=?
|
||||
WHERE vin=? AND stat_date=? AND protocol='JT808' AND source_key=?
|
||||
AND first_total_mileage_km <=> ? AND first_event_time <=> ? AND latest_total_mileage_km=? AND latest_event_time=?
|
||||
AND daily_mileage_km=? AND quality_status=?`, a.First, a.FirstTime, a.Last, a.LastTime, a.Daily, a.Status, a.Reason,
|
||||
b.VIN, b.Date, b.Source, b.First, b.FirstTime, b.Last, b.LastTime, b.Daily, b.Status)
|
||||
must(err)
|
||||
n, err := res.RowsAffected()
|
||||
must(err)
|
||||
if n != 1 {
|
||||
panic(fmt.Sprintf("concurrent change: %s %s %s", b.VIN, b.Date, b.Source))
|
||||
}
|
||||
targets[b.Date+"|"+b.VIN] = b
|
||||
}
|
||||
keys := make([]string, 0, len(targets))
|
||||
for k := range targets {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
projected, preserved := 0, 0
|
||||
for _, k := range keys {
|
||||
b := targets[k]
|
||||
rows, err := tx.QueryContext(ctx, `SELECT source_ip,COALESCE(quality_reason,'') FROM vehicle_daily_mileage_source WHERE vin=? AND stat_date=? AND protocol='JT808' AND is_selected=1 FOR UPDATE`, b.VIN, b.Date)
|
||||
must(err)
|
||||
manual := false
|
||||
for rows.Next() {
|
||||
var ip, reason string
|
||||
must(rows.Scan(&ip, &reason))
|
||||
manual = manual || strings.HasPrefix(ip, "manual") || strings.HasPrefix(ip, "legacy-mysql") || strings.HasPrefix(reason, "manual")
|
||||
}
|
||||
must(rows.Err())
|
||||
must(rows.Close())
|
||||
if manual {
|
||||
preserved++
|
||||
continue
|
||||
}
|
||||
must(stats.ProjectDailyMileage(ctx, tx, b.VIN, b.Date, envelope.ProtocolJT808))
|
||||
projected++
|
||||
}
|
||||
must(tx.Commit())
|
||||
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"committed": true, "updatedSources": len(plan), "reprojectedDays": projected, "preservedManualDays": preserved}))
|
||||
}
|
||||
|
||||
func validateChange(c change, loc *time.Location) error {
|
||||
b, a := c.Before, c.After
|
||||
if b.Protocol != "JT808" || b.VIN == "" || b.Source == "" {
|
||||
return fmt.Errorf("invalid GPS identity")
|
||||
}
|
||||
first, err := strconv.ParseFloat(a.First, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
last, err := strconv.ParseFloat(a.Last, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
daily, err := strconv.ParseFloat(a.Daily, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
firstTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.FirstTime, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.LastTime, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldEnd, err := time.ParseInLocation("2006-01-02 15:04:05", b.LastTime, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lastTime.Before(oldEnd) || firstTime.After(lastTime) || lastTime.Format("2006-01-02") != b.Date || first <= 0 || last <= 0 {
|
||||
return fmt.Errorf("invalid event-time boundary %s %s", b.VIN, b.Date)
|
||||
}
|
||||
sample := stats.SourceMileageSample{DailyKM: last - first, FirstEventTime: firstTime, LatestEventTime: lastTime, QualityStatus: stats.QualityOK, QualityReason: a.Reason}
|
||||
stats.ApplyMileageQualityRules(&sample)
|
||||
if math.IsNaN(daily) || math.IsInf(daily, 0) || math.Abs(sample.DailyKM-daily) > 0.0001 || sample.QualityStatus != a.Status {
|
||||
return fmt.Errorf("incorrect reviewed mileage %s %s", b.VIN, b.Date)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReviewedEventTimeRepairRequiresConsistentForwardTime(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
c := change{Before: row{VIN: "LGAX2BG42H1003112", Date: "2026-10-21", Protocol: "JT808", Source: "JT808:terminal@PLATFORM:forwarder", LastTime: "2026-10-21 08:59:34"},
|
||||
After: row{First: "44124.600", Last: "44402.100", FirstTime: "2026-10-20 23:29:53", LastTime: "2026-10-21 23:59:55", Daily: "277.500", Status: "OK", Reason: "historical_source_baseline"}}
|
||||
if err := validateChange(c, loc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*change){
|
||||
func(c *change) { c.After.LastTime = "2026-10-21 08:00:00" },
|
||||
func(c *change) { c.After.Daily = "353.800" },
|
||||
func(c *change) { c.Before.Protocol = "GB32960" },
|
||||
func(c *change) { c.After.FirstTime = "2026-10-22 00:00:00" },
|
||||
} {
|
||||
bad := c
|
||||
mutate(&bad)
|
||||
if err := validateChange(bad, loc); err == nil {
|
||||
t.Fatalf("unsafe reviewed plan accepted: %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,28 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if loaded, err := writer.ReloadSourceIdentities(ctx); err != nil {
|
||||
logger.Error("source identity snapshot load failed", "error", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_source_identity_snapshot_entries", nil, float64(loaded))
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if loaded, err := writer.ReloadSourceIdentities(ctx); err != nil {
|
||||
logger.Warn("source identity snapshot refresh failed; retaining previous snapshot", "error", err)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_source_identity_snapshot_entries", nil, float64(loaded))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
if cfg.HydrogenCapacitySyncEnabled {
|
||||
syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg)
|
||||
} else if loaded, err := writer.ReloadHydrogenTankCapacities(ctx); err != nil {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
const customerHydrogenAlgorithm = "MODE_INTERVAL_V1"
|
||||
|
||||
type customerHydrogenDay struct {
|
||||
VIN, Date, Protocol, SourceKey string
|
||||
HydrogenKM, ObservedKM, ClassifiedKM float64
|
||||
Samples, KnownSamples, Intervals, Rejected int64
|
||||
First, Last time.Time
|
||||
PreviousKM float64
|
||||
}
|
||||
|
||||
func addCustomerHydrogenSample(days map[string]*customerHydrogenDay, s stats.MetricSample) {
|
||||
if s.Protocol != envelope.ProtocolGB32960 && s.Protocol != envelope.ProtocolYutongMQTT {
|
||||
return
|
||||
}
|
||||
if math.IsNaN(s.TotalMileageKM) || math.IsInf(s.TotalMileageKM, 0) || s.TotalMileageKM < 0 {
|
||||
return
|
||||
}
|
||||
key := s.VIN + "/" + s.StatDate + "/" + string(s.Protocol) + "/" + s.SourceKey
|
||||
d := days[key]
|
||||
if d == nil {
|
||||
d = &customerHydrogenDay{VIN: s.VIN, Date: s.StatDate, Protocol: string(s.Protocol), SourceKey: s.SourceKey, First: s.EventTime, Last: s.EventTime, PreviousKM: s.TotalMileageKM, Samples: 1}
|
||||
if s.PureHydrogenModeKnown {
|
||||
d.KnownSamples = 1
|
||||
}
|
||||
days[key] = d
|
||||
return
|
||||
}
|
||||
// Do not count duplicate or out-of-order reports as extra evidence.
|
||||
if !s.EventTime.After(d.Last) {
|
||||
return
|
||||
}
|
||||
elapsed := s.EventTime.Sub(d.Last)
|
||||
delta := s.TotalMileageKM - d.PreviousKM
|
||||
d.Samples++
|
||||
if s.PureHydrogenModeKnown {
|
||||
d.KnownSamples++
|
||||
}
|
||||
if delta < -0.2 || delta > math.Max(0.5, elapsed.Hours()*130) || elapsed > 15*time.Minute {
|
||||
d.Rejected++
|
||||
} else {
|
||||
delta = math.Max(0, delta)
|
||||
d.ObservedKM += delta
|
||||
d.Intervals++
|
||||
if s.PureHydrogenModeKnown {
|
||||
d.ClassifiedKM += delta
|
||||
if s.PureHydrogenActive {
|
||||
d.HydrogenKM += delta
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Last = s.EventTime
|
||||
d.PreviousKM = s.TotalMileageKM
|
||||
}
|
||||
|
||||
// This path reads raw work-mode evidence and only writes the dedicated customer
|
||||
// monitor projection. It never resets/reprojects the existing GPS/daily tables.
|
||||
func runCustomerHydrogenBackfill(ctx context.Context, mysqlDB, td *sql.DB, cfg config) error {
|
||||
return runCustomerHydrogenBackfillWithLease(ctx, mysqlDB, td, cfg, "")
|
||||
}
|
||||
|
||||
func runCustomerHydrogenBackfillWithLease(ctx context.Context, mysqlDB, td *sql.DB, cfg config, owner string) error {
|
||||
if cfg.Reset {
|
||||
return fmt.Errorf("hydrogen-only backfill does not allow reset")
|
||||
}
|
||||
if cfg.Limit > 0 && !cfg.DryRun {
|
||||
return fmt.Errorf("limited scan cannot publish a daily result")
|
||||
}
|
||||
for _, p := range cfg.Protocols {
|
||||
if p != envelope.ProtocolGB32960 && p != envelope.ProtocolYutongMQTT {
|
||||
return fmt.Errorf("hydrogen-only backfill accepts GB32960,YUTONG_MQTT only")
|
||||
}
|
||||
}
|
||||
rows, err := queryRawFrames(ctx, td, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
days := map[string]*customerHydrogenDay{}
|
||||
var scanned, skipped int64
|
||||
for rows.Next() {
|
||||
scanned++
|
||||
row, err := scanRawFrame(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := strings.TrimSpace(row.ParsedJSON)
|
||||
if isChunkManifest(text) {
|
||||
text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read complete mode evidence: %w", err)
|
||||
}
|
||||
}
|
||||
fields := fieldsForStats(row.Protocol, row.VIN, text)
|
||||
if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT {
|
||||
fields = fieldsForStats(row.Protocol, row.VIN, row.RawText)
|
||||
}
|
||||
samples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{EventID: row.EventID, Protocol: row.Protocol, MessageID: row.MessageID, VIN: row.VIN, Phone: row.Phone, DeviceID: row.DeviceID, SourceEndpoint: row.SourceEndpoint, EventTimeMS: row.EventTimeMS, ReceivedAtMS: row.ReceivedAtMS, Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK}, cfg.Location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
skipped++
|
||||
}
|
||||
for _, s := range samples {
|
||||
if s.StatDate >= cfg.DateFrom && s.StatDate <= cfg.DateTo {
|
||||
addCustomerHydrogenSample(days, s)
|
||||
}
|
||||
}
|
||||
if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 {
|
||||
slog.Info("customer hydrogen scan", "scanned", scanned, "source_days", len(days), "skipped", skipped)
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var known, samples int64
|
||||
var hydrogen float64
|
||||
for _, d := range days {
|
||||
known += d.KnownSamples
|
||||
samples += d.Samples
|
||||
hydrogen += d.HydrogenKM
|
||||
}
|
||||
slog.Info("customer hydrogen evidence", "scanned", scanned, "samples", samples, "known_mode_samples", known, "source_days", len(days), "hydrogen_km_across_sources", hydrogen, "dry_run", cfg.DryRun)
|
||||
if cfg.DryRun {
|
||||
return nil
|
||||
}
|
||||
return publishCustomerHydrogenDays(ctx, mysqlDB, days, cfg.DateFrom, owner)
|
||||
}
|
||||
|
||||
func publishCustomerHydrogenDays(ctx context.Context, mysqlDB *sql.DB, days map[string]*customerHydrogenDay, date, owner string) error {
|
||||
// Publish only after a complete successful scan. Upserts are deterministic;
|
||||
// failures roll back the entire projection update.
|
||||
tx, err := mysqlDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if owner != "" {
|
||||
// Fence publication with the same row lock used to claim jobs. A stale
|
||||
// worker must not overwrite results even before its next heartbeat.
|
||||
var valid int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT 1 FROM platform_customer_hydrogen_job WHERE stat_date=? AND lease_owner=? AND status='running' AND lease_until>NOW(3) FOR UPDATE`, date, owner).Scan(&valid); err != nil {
|
||||
return fmt.Errorf("customer hydrogen publication lease lost: %w", err)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(days))
|
||||
for k := range days {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for start := 0; start < len(keys); start += 200 {
|
||||
end := start + 200
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
values := []string{}
|
||||
args := []any{}
|
||||
for _, key := range keys[start:end] {
|
||||
d := days[key]
|
||||
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3))")
|
||||
args = append(args, d.VIN, d.Date, d.Protocol, d.SourceKey, d.HydrogenKM, d.ObservedKM, d.ClassifiedKM, d.Samples, d.KnownSamples, d.Intervals, d.Rejected, d.First, d.Last, customerHydrogenAlgorithm)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_hydrogen_daily(vin,stat_date,protocol,source_key,hydrogen_km,observed_km,classified_km,sample_count,known_sample_count,interval_count,rejected_intervals,first_event_time,last_event_time,algorithm_version,calculated_at) VALUES `+strings.Join(values, ",")+` ON DUPLICATE KEY UPDATE hydrogen_km=VALUES(hydrogen_km),observed_km=VALUES(observed_km),classified_km=VALUES(classified_km),sample_count=VALUES(sample_count),known_sample_count=VALUES(known_sample_count),interval_count=VALUES(interval_count),rejected_intervals=VALUES(rejected_intervals),first_event_time=VALUES(first_event_time),last_event_time=VALUES(last_event_time),algorithm_version=VALUES(algorithm_version),calculated_at=VALUES(calculated_at)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCustomerHydrogenIntervalsClassifyElectricZeroAndUnknown(t *testing.T) {
|
||||
start := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC)
|
||||
days := map[string]*customerHydrogenDay{}
|
||||
sample := func(min int, km float64, known, active bool) stats.MetricSample {
|
||||
return stats.MetricSample{VIN: "VIN", StatDate: "2026-09-29", Protocol: envelope.ProtocolGB32960, SourceKey: "source", EventTime: start.Add(time.Duration(min) * time.Minute), TotalMileageKM: km, PureHydrogenModeKnown: known, PureHydrogenActive: active}
|
||||
}
|
||||
for _, s := range []stats.MetricSample{sample(0, 100, true, false), sample(5, 105, true, false), sample(10, 110, true, true), sample(15, 115, false, false), sample(15, 115, true, true), sample(60, 130, true, true), sample(65, 140, true, true)} {
|
||||
addCustomerHydrogenSample(days, s)
|
||||
}
|
||||
for _, d := range days {
|
||||
if d.HydrogenKM != 15 || d.ObservedKM != 25 || d.ClassifiedKM != 20 || d.Samples != 6 || d.KnownSamples != 5 || d.Rejected != 1 {
|
||||
t.Fatalf("incorrect evidence %+v", d)
|
||||
}
|
||||
}
|
||||
zero := map[string]*customerHydrogenDay{}
|
||||
addCustomerHydrogenSample(zero, sample(0, 100, true, false))
|
||||
addCustomerHydrogenSample(zero, sample(5, 105, true, false))
|
||||
for _, d := range zero {
|
||||
if d.HydrogenKM != 0 || d.ClassifiedKM != 5 || d.KnownSamples != 2 {
|
||||
t.Fatal("observed electric-only day not distinguished from missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCustomerHydrogenSeparatesSourcesDaysAndRejectsJumps(t *testing.T) {
|
||||
days := map[string]*customerHydrogenDay{}
|
||||
s := stats.MetricSample{VIN: "VIN", StatDate: "2026-09-29", Protocol: envelope.ProtocolGB32960, SourceKey: "A", EventTime: time.Now(), TotalMileageKM: 100, PureHydrogenModeKnown: true, PureHydrogenActive: true}
|
||||
addCustomerHydrogenSample(days, s)
|
||||
s.EventTime = s.EventTime.Add(time.Minute)
|
||||
s.TotalMileageKM = 1100
|
||||
addCustomerHydrogenSample(days, s)
|
||||
s.SourceKey = "B"
|
||||
s.TotalMileageKM = 100
|
||||
addCustomerHydrogenSample(days, s)
|
||||
s.StatDate = "2026-09-30"
|
||||
addCustomerHydrogenSample(days, s)
|
||||
if len(days) != 3 {
|
||||
t.Fatal("source/day evidence mixed")
|
||||
}
|
||||
for _, d := range days {
|
||||
if d.HydrogenKM != 0 {
|
||||
t.Fatal("odometer jump counted as hydrogen")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runCustomerHydrogenWorker(parent context.Context, mysqlDB, td *sql.DB, cfg config) error {
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
maxJobs := envInt("CUSTOMER_HYDROGEN_MAX_JOBS", 0)
|
||||
completedJobs := 0
|
||||
for ctx.Err() == nil {
|
||||
date, owner, err := claimCustomerHydrogenDay(ctx, mysqlDB)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
slog.Error("customer hydrogen claim failed", "error", err)
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(5 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
dayCtx, cancel := context.WithTimeout(ctx, 2*time.Hour)
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-dayCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
result, e := mysqlDB.ExecContext(dayCtx, `UPDATE platform_customer_hydrogen_job SET lease_until=DATE_ADD(NOW(3),INTERVAL 5 MINUTE) WHERE stat_date=? AND lease_owner=? AND status='running'`, date, owner)
|
||||
if e != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
n, e := result.RowsAffected()
|
||||
if e != nil || n != 1 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
task := cfg
|
||||
task.DateFrom = date
|
||||
task.DateTo = date
|
||||
task.Limit = 0
|
||||
task.DryRun = false
|
||||
task.Reset = false
|
||||
task.VINs = nil
|
||||
slog.Info("customer hydrogen job started", "date", date)
|
||||
err = runCustomerHydrogenBackfillWithLease(dayCtx, mysqlDB, td, task, owner)
|
||||
cancel()
|
||||
<-heartbeatDone
|
||||
status, message := "complete", ""
|
||||
if err != nil {
|
||||
status, message = "failed", "原始报文计算失败,请检查任务日志"
|
||||
slog.Error("customer hydrogen job failed", "date", date, "error", err)
|
||||
}
|
||||
// A cancelled/expired worker cannot acknowledge another worker's lease.
|
||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, saveErr := mysqlDB.ExecContext(finishCtx, `UPDATE platform_customer_hydrogen_job SET status=?,error_message=?,completed_at=IF(?='complete',NOW(3),completed_at),lease_until=NULL,lease_owner='' WHERE stat_date=? AND lease_owner=?`, status, message, status, date, owner)
|
||||
finishCancel()
|
||||
if saveErr != nil {
|
||||
slog.Error("customer hydrogen job completion failed", "date", date, "error", saveErr)
|
||||
}
|
||||
completedJobs++
|
||||
if maxJobs > 0 && completedJobs >= maxJobs {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claimCustomerHydrogenDay(ctx context.Context, db *sql.DB) (string, string, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var date string
|
||||
err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM platform_customer_hydrogen_job WHERE status='pending' OR (status='running' AND lease_until<NOW(3)) OR (status='failed' AND attempts<3 AND started_at<DATE_SUB(NOW(3),INTERVAL 1 MINUTE)) ORDER BY requested_at ASC,stat_date DESC LIMIT 1 FOR UPDATE`).Scan(&date)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var bytes [16]byte
|
||||
if _, err = rand.Read(bytes[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
owner := hex.EncodeToString(bytes[:])
|
||||
_, err = tx.ExecContext(ctx, `UPDATE platform_customer_hydrogen_job SET status='running',started_at=NOW(3),lease_until=DATE_ADD(NOW(3),INTERVAL 5 MINUTE),lease_owner=?,attempts=attempts+1,error_message='' WHERE stat_date=?`, owner, date)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return date, owner, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestCustomerHydrogenJobClaimUsesTransactionAndOwner(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-09-29"))
|
||||
mock.ExpectExec("UPDATE platform_customer_hydrogen_job").WithArgs(sqlmock.AnyArg(), "2026-09-29").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
date, owner, err := claimCustomerHydrogenDay(context.Background(), db)
|
||||
if err != nil || date != "2026-09-29" || len(owner) != 32 {
|
||||
t.Fatalf("invalid claim %s %s %v", date, owner, err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHydrogenBoundedWorkerPublishesOneJobThenExits(t *testing.T) {
|
||||
t.Setenv("CUSTOMER_HYDROGEN_MAX_JOBS", "1")
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
td, raw, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer td.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-09-29"))
|
||||
mock.ExpectExec("UPDATE platform_customer_hydrogen_job").WithArgs(sqlmock.AnyArg(), "2026-09-29").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
raw.ExpectQuery("SELECT").WillReturnRows(sqlmock.NewRows([]string{"event_id"}))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT 1.*FOR UPDATE").WithArgs("2026-09-29", sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"valid"}).AddRow(1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("UPDATE platform_customer_hydrogen_job SET status").WithArgs("complete", "", "complete", "2026-09-29", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
err = runCustomerHydrogenWorker(ctx, db, td, config{Location: time.UTC, TDengineDatabase: "vehicle_ingest", Protocols: []envelope.Protocol{envelope.ProtocolGB32960}})
|
||||
if err != nil || ctx.Err() != nil {
|
||||
t.Fatalf("bounded worker did not exit after publication: %v / %v", err, ctx.Err())
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = raw.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHydrogenStaleWorkerCannotPublish(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT 1.*lease_until>NOW.*FOR UPDATE").WithArgs("2026-09-29", "old-owner").WillReturnRows(sqlmock.NewRows([]string{"valid"}))
|
||||
mock.ExpectRollback()
|
||||
days := map[string]*customerHydrogenDay{"sample": {VIN: "VIN", Date: "2026-09-29", HydrogenKM: 10}}
|
||||
if err = publishCustomerHydrogenDays(context.Background(), db, days, "2026-09-29", "old-owner"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("stale worker must fail before writing: %v", err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHydrogenPublicationLocksCurrentLease(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT 1.*lease_until>NOW.*FOR UPDATE").WithArgs("2026-09-29", "current-owner").WillReturnRows(sqlmock.NewRows([]string{"valid"}).AddRow(1))
|
||||
mock.ExpectExec("INSERT INTO platform_customer_hydrogen_daily").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
days := map[string]*customerHydrogenDay{"sample": {VIN: "VIN", Date: "2026-09-29", HydrogenKM: 10}}
|
||||
if err = publishCustomerHydrogenDays(context.Background(), db, days, "2026-09-29", "current-owner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestCustomerHydrogenIdleWorkerDoesNotCreateJobs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"date"}))
|
||||
mock.ExpectRollback()
|
||||
if _, _, err = claimCustomerHydrogenDay(context.Background(), db); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
var errNoUsableSourceBoundary = errors.New("source has no usable mileage boundary")
|
||||
|
||||
// FIRST/LAST are ordered by TDengine's storage timestamp, which represents
|
||||
// receipt order. Keep the fast aggregate path, but independently verify both
|
||||
// ends against extrema of the vehicle event timestamp and fetch the matching
|
||||
// complete row whenever late delivery has moved either end.
|
||||
type sourceBoundaryEvidence struct {
|
||||
vin, phone, deviceID, endpoint string
|
||||
firstTS, lastTS time.Time
|
||||
firstParsed, firstRaw string
|
||||
lastParsed, lastRaw string
|
||||
count, minimumMS, maximumMS int64
|
||||
}
|
||||
|
||||
func correctSourceEventBoundaries(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, includeFirst bool, e *sourceBoundaryEvidence) error {
|
||||
_, firstUsable := mileageFromEvidence(protocol, e.vin, e.firstParsed, e.firstRaw, e.firstTS, cfg.Location)
|
||||
_, lastUsable := mileageFromEvidence(protocol, e.vin, e.lastParsed, e.lastRaw, e.lastTS, cfg.Location)
|
||||
if e.lastTS.UnixMilli() != e.maximumMS || !lastUsable {
|
||||
stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.maximumMS, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) && !firstUsable && !lastUsable {
|
||||
return errNoUsableSourceBoundary
|
||||
}
|
||||
return err
|
||||
}
|
||||
slog.Info("corrected last source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_last", e.lastTS, "event_last", stamp)
|
||||
e.lastTS, e.lastParsed, e.lastRaw = stamp, parsed, raw
|
||||
}
|
||||
if !includeFirst {
|
||||
e.firstTS, e.firstParsed, e.firstRaw = e.lastTS, e.lastParsed, e.lastRaw
|
||||
return nil
|
||||
}
|
||||
if e.firstTS.UnixMilli() != e.minimumMS || !firstUsable {
|
||||
stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.minimumMS, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("corrected first source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_first", e.firstTS, "event_first", stamp)
|
||||
e.firstTS, e.firstParsed, e.firstRaw = stamp, parsed, raw
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func querySourceEventBoundary(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, e sourceBoundaryEvidence, eventMS int64, first bool) (time.Time, string, string, error) {
|
||||
// An equal event timestamp is resolved consistently by newest storage ts.
|
||||
// Never obtain the timestamp and payload through independent aggregates.
|
||||
comparison, order := "<=", "DESC"
|
||||
if first {
|
||||
comparison, order = ">=", "ASC"
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT event_time, parsed_json, raw_text
|
||||
FROM %s.raw_frames
|
||||
WHERE %s AND vin = '%s' AND phone = '%s' AND device_id = '%s'
|
||||
AND source_endpoint = '%s' AND CAST(event_time AS BIGINT) %s %d
|
||||
ORDER BY event_time %s, ts DESC`, ident(cfg.TDengineDatabase), where, quote(e.vin), quote(e.phone), quote(e.deviceID), quote(e.endpoint), comparison, eventMS, order)
|
||||
rows, err := db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return time.Time{}, "", "", fmt.Errorf("resolve event-time boundary %s/%s/%d: %w", e.vin, e.endpoint, eventMS, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var stamp time.Time
|
||||
var parsed, raw string
|
||||
if err := rows.Scan(&stamp, &parsed, &raw); err != nil {
|
||||
return time.Time{}, "", "", err
|
||||
}
|
||||
if _, ok := mileageFromEvidence(protocol, e.vin, parsed, raw, stamp, cfg.Location); ok {
|
||||
return stamp, parsed, raw, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return time.Time{}, "", "", err
|
||||
}
|
||||
return time.Time{}, "", "", sql.ErrNoRows
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestDailySourceEndUsesVehicleEventTimeAfterLateDelivery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
vin, phone, ip, code string
|
||||
lateHour, lateMin int
|
||||
endHour, endMin int
|
||||
lateKM, endKM float64
|
||||
}{
|
||||
{"LGAX2BG42H1003112", "40409727033", "122.152.221.156", "dongfang_beidou", 8, 59, 23, 59, 44478.4, 44402.1},
|
||||
{"LA9HE60A0PBAF4002", "13307765803", "115.231.168.135", "xinda", 20, 46, 21, 14, 6194.8, 6195.1},
|
||||
} {
|
||||
t.Run(tc.vin, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
first := time.Date(2026, 10, 21, 0, 0, 0, 0, loc)
|
||||
late := time.Date(2026, 10, 21, tc.lateHour, tc.lateMin, 0, 0, loc)
|
||||
last := time.Date(2026, 10, 21, tc.endHour, tc.endMin, 0, 0, loc)
|
||||
parsed := func(km float64) string { return fmt.Sprintf(`{"jt808.location.total_mileage_km":%f}`, km) }
|
||||
mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST\\(event_time AS BIGINT\\)\\).*MAX\\(CAST\\(event_time AS BIGINT\\)\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}).
|
||||
AddRow(tc.vin, tc.phone, "", tc.ip+":1234", first, parsed(tc.endKM-100), "", late, parsed(tc.lateKM), "", 100, first.UnixMilli(), last.UnixMilli())).RowsWillBeClosed()
|
||||
mock.ExpectQuery(fmt.Sprintf("(?s)SELECT event_time, parsed_json, raw_text.*phone = '%s'.*CAST\\(event_time AS BIGINT\\) <= %d.*ORDER BY event_time DESC, ts DESC", tc.phone, last.UnixMilli())).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"event_time", "parsed_json", "raw_text"}).AddRow(last, parsed(tc.endKM), ""))
|
||||
cfg := config{TDengineDatabase: "lingniu_vehicle_ts", Location: loc}
|
||||
result, err := queryDailyLastSourceRows(context.Background(), db, cfg, envelope.ProtocolJT808, "2026-10-21")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := result[tc.vin][0]
|
||||
if !got.TS.Equal(last) || math.Abs(got.TotalKM-tc.endKM) > 0.0001 || got.RawSampleCount != 100 {
|
||||
t.Fatalf("late delivery replaced the vehicle day end: %+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySourceFirstAlsoUsesVehicleEventTime(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
first := time.Date(2026, 10, 21, 8, 0, 0, 0, loc)
|
||||
storageFirst := first.Add(time.Hour)
|
||||
last := first.Add(10 * time.Hour)
|
||||
mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}).
|
||||
AddRow("VIN", "phone", "", "1.2.3.4:123", storageFirst, `{"jt808.location.total_mileage_km":101}`, "", last, `{"jt808.location.total_mileage_km":110}`, "", 5, first.UnixMilli(), last.UnixMilli()))
|
||||
mock.ExpectQuery(fmt.Sprintf("(?s)SELECT event_time.*CAST\\(event_time AS BIGINT\\) >= %d", first.UnixMilli())).WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"}).AddRow(first, `{"jt808.location.total_mileage_km":100}`, ""))
|
||||
got, err := queryDailyLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: loc}, envelope.ProtocolJT808, "2026-10-21")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["VIN"][0].FirstTotalKM != 100 || !got["VIN"][0].FirstTS.Equal(first) {
|
||||
t.Fatalf("wrong first event: %+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingVehicleEventBoundaryFailsBeforeWriting(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
stamp := time.Date(2026, 10, 20, 20, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery("(?s)SELECT vin.*MAX\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "last", "parsed", "raw", "count", "maximum"}).AddRow("VIN", "phone", "", "1.2.3.4:123", stamp, `{"jt808.location.total_mileage_km":100}`, "", 2, stamp.Add(time.Hour).UnixMilli()))
|
||||
mock.ExpectQuery("SELECT event_time").WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"}))
|
||||
if _, err := queryPreviousLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: time.UTC}, envelope.ProtocolJT808, "2026-10-21"); err == nil {
|
||||
t.Fatal("missing event boundary silently accepted")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroMileageTailDoesNotHideLastUsableVehicleEvent(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
first := time.Date(2026, 10, 21, 8, 0, 0, 0, loc)
|
||||
last := first.Add(time.Hour)
|
||||
zero := last.Add(time.Hour)
|
||||
mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}).AddRow("VIN", "phone", "", "1.2.3.4:123", first, `{"jt808.location.total_mileage_km":100}`, "", zero, `{"jt808.location.total_mileage_km":0}`, "", 3, first.UnixMilli(), zero.UnixMilli()))
|
||||
mock.ExpectQuery("(?s)SELECT event_time.*ORDER BY event_time DESC, ts DESC").WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"}).AddRow(zero, `{"jt808.location.total_mileage_km":0}`, "").AddRow(last, `{"jt808.location.total_mileage_km":110}`, ""))
|
||||
got, err := queryDailyLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: loc}, envelope.ProtocolJT808, "2026-10-21")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["VIN"][0].TotalKM != 110 || !got["VIN"][0].TS.Equal(last) {
|
||||
t.Fatalf("non-positive frame replaced usable end: %+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -43,6 +44,7 @@ type config struct {
|
||||
ListHydrogenVINs bool
|
||||
ListActiveVINs bool
|
||||
Location *time.Location
|
||||
SourceCatalog map[backfillSourceID]stats.SourceIdentity
|
||||
}
|
||||
|
||||
type rawFrameRow struct {
|
||||
@@ -150,9 +152,27 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
|
||||
if err := schemaWriter.EnsureSchema(ctx); err != nil {
|
||||
fail("ensure schema", err)
|
||||
if envBool("CUSTOMER_HYDROGEN_WORKER", false) {
|
||||
if err := runCustomerHydrogenWorker(ctx, mysqlDB, td, cfg); err != nil {
|
||||
fail("customer hydrogen worker", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if envBool("BACKFILL_HYDROGEN_ONLY", false) {
|
||||
if err := runCustomerHydrogenBackfill(ctx, mysqlDB, td, cfg); err != nil {
|
||||
fail("customer hydrogen backfill", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
cfg.SourceCatalog, err = loadBackfillSourceCatalog(ctx, mysqlDB)
|
||||
if err != nil {
|
||||
fail("load backfill source catalog", err)
|
||||
}
|
||||
if !cfg.DryRun {
|
||||
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
|
||||
if err := schemaWriter.EnsureSchema(ctx); err != nil {
|
||||
fail("ensure schema", err)
|
||||
}
|
||||
}
|
||||
if cfg.Reset && !cfg.DryRun {
|
||||
deleted, err := resetStats(ctx, mysqlDB, cfg)
|
||||
@@ -178,6 +198,9 @@ func main() {
|
||||
if err != nil {
|
||||
fail("cleanup sub-threshold GPS mileage candidates", err)
|
||||
}
|
||||
if cfg.Debug {
|
||||
logBackfillCandidates(aggregates)
|
||||
}
|
||||
var written int64
|
||||
var normalized int
|
||||
if !cfg.DryRun {
|
||||
@@ -250,6 +273,7 @@ func main() {
|
||||
ParsedFields: fields,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
env.SourceCode, env.PlatformName, env.SourceKind = backfillSourceMetadata(cfg, row.Protocol, row.SourceEndpoint)
|
||||
samples, err := stats.SamplesFromEnvelope(env, cfg.Location)
|
||||
if err != nil {
|
||||
slog.Warn("skip invalid sample", "event_id", row.EventID, "error", err)
|
||||
@@ -347,6 +371,23 @@ func activeModeSampleCount(sample stats.MetricSample) int64 {
|
||||
}
|
||||
|
||||
func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) {
|
||||
gps, other := map[string]*metricAgg{}, map[string]*metricAgg{}
|
||||
for key, agg := range aggregates {
|
||||
if agg.Protocol == envelope.ProtocolJT808 {
|
||||
gps[key] = agg
|
||||
} else {
|
||||
other[key] = agg
|
||||
}
|
||||
}
|
||||
written, err := writeProtectedGPSAggregates(ctx, db, gps)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
n, err := writeLegacyAggregates(ctx, db, other, batchSize)
|
||||
return written + n, err
|
||||
}
|
||||
|
||||
func writeLegacyAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) {
|
||||
if len(aggregates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -429,11 +470,25 @@ func normalizeHistoricalPlatformSources(ctx context.Context, db *sql.DB, cfg con
|
||||
continue
|
||||
}
|
||||
for _, date := range dates {
|
||||
count, err := stats.NormalizePlatformSourceMileageForDate(ctx, db, date, protocol)
|
||||
if len(cfg.VINs) > 0 {
|
||||
for _, vin := range cfg.VINs {
|
||||
if err := normalizeProtectedGPSDay(ctx, db, vin, date); err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized++
|
||||
}
|
||||
continue
|
||||
}
|
||||
vins, err := stats.PlatformSourceMileageLegacyVINs(ctx, db, date, protocol)
|
||||
if err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized += count
|
||||
for _, vin := range vins {
|
||||
if err := normalizeProtectedGPSDay(ctx, db, vin, date); err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized++
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
@@ -592,7 +647,8 @@ FROM vehicle_daily_mileage_source
|
||||
WHERE stat_date >= ? AND stat_date <= ?
|
||||
AND quality_reason = ?
|
||||
AND daily_mileage_km >= 0 AND daily_mileage_km < ?
|
||||
AND protocol IN (` + strings.Join(placeholders, ",") + `)
|
||||
AND protocol IN (` + strings.Join(placeholders, ",") + `)`
|
||||
query = appendBackfillVINScope(query, cfg, "vin") + `
|
||||
GROUP BY vin, stat_date, protocol
|
||||
ORDER BY stat_date, protocol, vin`
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
@@ -697,6 +753,9 @@ func addProtocolGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sq
|
||||
"latitude BETWEEN -90 AND 90",
|
||||
"NOT (longitude = 0 AND latitude = 0)",
|
||||
}
|
||||
if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
if protocol != envelope.ProtocolJT808 {
|
||||
where = append(where, "speed_kmh > 0")
|
||||
}
|
||||
@@ -1060,13 +1119,17 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
|
||||
FIRST(event_time), FIRST(parsed_json), FIRST(raw_text),
|
||||
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
|
||||
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*),
|
||||
MIN(CAST(event_time AS BIGINT)), MAX(CAST(event_time AS BIGINT))
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, true)
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, true, strings.Join(where, " AND "))
|
||||
}
|
||||
|
||||
func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
@@ -1085,12 +1148,15 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
|
||||
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
|
||||
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*), MAX(CAST(event_time AS BIGINT))
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, false, strings.Join(where, " AND "))
|
||||
}
|
||||
|
||||
func backfillBeforePredicates(cfg config, eventDateExclusive string) []string {
|
||||
@@ -1141,6 +1207,7 @@ WHERE l.protocol = ?
|
||||
if cfg.StationaryCarry {
|
||||
args = append(args, date)
|
||||
}
|
||||
sqlText = appendBackfillVINScope(sqlText, cfg, "l.vin")
|
||||
rows, err := db.QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1167,7 +1234,7 @@ WHERE l.protocol = ?
|
||||
if protocol == envelope.ProtocolYutongMQTT {
|
||||
deviceID = vin
|
||||
}
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceCode, platformName, sourceKind := backfillSourceMetadata(cfg, protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), "", deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
@@ -1194,37 +1261,46 @@ WHERE l.protocol = ?
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool) (map[string][]dailySourceLast, error) {
|
||||
func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool, boundaryWhere string) (map[string][]dailySourceLast, error) {
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
latestBySource := map[string]dailySourceLast{}
|
||||
var evidence []sourceBoundaryEvidence
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var phone string
|
||||
var deviceID string
|
||||
var sourceEndpoint string
|
||||
var firstTS time.Time
|
||||
var firstParsedJSON string
|
||||
var firstRawText string
|
||||
var ts time.Time
|
||||
var parsedJSON string
|
||||
var rawText string
|
||||
var rawSampleCount int64
|
||||
var e sourceBoundaryEvidence
|
||||
if includeFirst {
|
||||
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
|
||||
if err := rows.Scan(&e.vin, &e.phone, &e.deviceID, &e.endpoint, &e.firstTS, &e.firstParsed, &e.firstRaw, &e.lastTS, &e.lastParsed, &e.lastRaw, &e.count, &e.minimumMS, &e.maximumMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
|
||||
if err := rows.Scan(&e.vin, &e.phone, &e.deviceID, &e.endpoint, &e.lastTS, &e.lastParsed, &e.lastRaw, &e.count, &e.maximumMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstTS = ts
|
||||
firstParsedJSON = parsedJSON
|
||||
firstRawText = rawText
|
||||
e.minimumMS = e.maximumMS
|
||||
}
|
||||
evidence = append(evidence, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Release the aggregate cursor before resolving exceptional event-time
|
||||
// boundaries; this also works with a single TDengine connection.
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latestBySource := map[string]dailySourceLast{}
|
||||
for _, e := range evidence {
|
||||
if err := correctSourceEventBoundaries(ctx, db, cfg, protocol, boundaryWhere, includeFirst, &e); err != nil {
|
||||
if errors.Is(err, errNoUsableSourceBoundary) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
vin, phone, deviceID, sourceEndpoint := e.vin, e.phone, e.deviceID, e.endpoint
|
||||
firstTS, firstParsedJSON, firstRawText := e.firstTS, e.firstParsed, e.firstRaw
|
||||
ts, parsedJSON, rawText, rawSampleCount := e.lastTS, e.lastParsed, e.lastRaw, e.count
|
||||
latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -1235,7 +1311,7 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
firstTotalKM = parsedFirst
|
||||
}
|
||||
}
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceCode, platformName, sourceKind := backfillSourceMetadata(cfg, protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), phone, deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
@@ -1256,9 +1332,10 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
RawSampleCount: rawSampleCount,
|
||||
}
|
||||
key := row.VIN + "|" + row.SourceKey
|
||||
if existing, ok := latestBySource[key]; !ok || row.TS.After(existing.TS) {
|
||||
latestBySource[key] = row
|
||||
if existing, ok := latestBySource[key]; ok {
|
||||
row = mergeDailySourceRows(existing, row)
|
||||
}
|
||||
latestBySource[key] = row
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -1731,6 +1808,9 @@ func resetStats(ctx context.Context, db *sql.DB, cfg config) (int64, error) {
|
||||
}
|
||||
clauses = append(clauses, "protocol IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" {
|
||||
clauses = append(clauses, predicate)
|
||||
}
|
||||
var deleted int64
|
||||
for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage"} {
|
||||
result, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+strings.Join(clauses, " AND "), args...)
|
||||
|
||||
@@ -302,23 +302,21 @@ func TestBuildLastDiffAggregatesRetainsBoundaryAfterEmptyDays(t *testing.T) {
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
currentRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
||||
})
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MIN_EVENT_MS", "MAX_EVENT_MS"})
|
||||
}
|
||||
previousRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
||||
})
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"})
|
||||
}
|
||||
|
||||
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(previousRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4)))
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4), dayOneTS.UnixMilli(), dayOneTS.UnixMilli()))
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6)))
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6), dayThreeTS.UnixMilli(), dayThreeTS.UnixMilli()))
|
||||
|
||||
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
@@ -632,20 +630,7 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
"LMRKH9AC6R1004108",
|
||||
"mqtt://yutong/ytforward/shln/3",
|
||||
firstTS,
|
||||
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65422000}}`,
|
||||
lastTS,
|
||||
`{"yutong_mqtt.data.latitude":"30.1"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65423000}}`,
|
||||
int64(42),
|
||||
))
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MIN_EVENT_MS", "MAX_EVENT_MS"}).AddRow("LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", firstTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65422000}}`, lastTS, `{"yutong_mqtt.data.latitude":"30.1"}`, `{"data":{"TOTAL_MILEAGE":65423000}}`, int64(42), firstTS.UnixMilli(), lastTS.UnixMilli()))
|
||||
|
||||
rows, err := queryDailyLastSourceRows(context.Background(), db, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
@@ -677,17 +662,7 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
"LMRKH9AC6R1004108",
|
||||
"mqtt://yutong/ytforward/shln/3",
|
||||
lastTS,
|
||||
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65377000}}`,
|
||||
int64(31),
|
||||
))
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"}).AddRow("LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", lastTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65377000}}`, int64(31), lastTS.UnixMilli()))
|
||||
|
||||
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
@@ -735,17 +710,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
|
||||
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC7R1004098",
|
||||
"",
|
||||
"LMRKH9AC7R1004098",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
utcInstant,
|
||||
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
|
||||
"",
|
||||
int64(1),
|
||||
))
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"}).AddRow("LMRKH9AC7R1004098", "", "LMRKH9AC7R1004098", "mqtt://yutong/ytforward/shln/4", utcInstant, `{"yutong_mqtt.data.total_mileage":"41249000"}`, "", int64(1), utcInstant.UnixMilli()))
|
||||
|
||||
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
@@ -866,106 +831,6 @@ func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.MatchExpectationsInOrder(true)
|
||||
|
||||
projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
aggregates := map[string]*metricAgg{
|
||||
"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": {
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
Date: "2026-07-08",
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
FirstKM: 4100,
|
||||
LatestKM: 4101,
|
||||
Count: 1,
|
||||
SourceKey: "JT808:13307765812@115.231.168.135",
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:20215",
|
||||
FirstEventTime: projNow,
|
||||
LatestEventTime: projNow,
|
||||
QualityStatus: stats.QualityInvalidDelta,
|
||||
QualityReason: "outside_daily_range",
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
"JT808:13307765812@115.231.168.135",
|
||||
"115.231.168.135",
|
||||
"115.231.168.135:20215",
|
||||
"13307765812",
|
||||
"",
|
||||
"",
|
||||
float64(4100),
|
||||
float64(4101),
|
||||
float64(1),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
projNow,
|
||||
projNow,
|
||||
false,
|
||||
false,
|
||||
stats.QualityInvalidDelta,
|
||||
"outside_daily_range",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 0))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
int64(2500),
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
written, err := writeAggregates(context.Background(), db, aggregates, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("writeAggregates() error = %v", err)
|
||||
}
|
||||
if written != 1 {
|
||||
t.Fatalf("written = %d, want 1", written)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
// Rebuild each GPS vehicle-day atomically. Retained RAW is not guaranteed to
|
||||
// contain every source or a later sample already accepted by the live writer.
|
||||
// Preserve those durable candidates instead of deleting the complete day.
|
||||
func writeProtectedGPSAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg) (int64, error) {
|
||||
groups := map[string][]*metricAgg{}
|
||||
for _, agg := range aggregates {
|
||||
if strings.TrimSpace(stats.NormalizeSourceIP(agg.SourceEndpoint)) == "" {
|
||||
continue
|
||||
}
|
||||
key := agg.VIN + "|" + agg.Date
|
||||
groups[key] = append(groups[key], agg)
|
||||
}
|
||||
keys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var written int64
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
sort.Slice(group, func(i, j int) bool { return group[i].SourceKey < group[j].SourceKey })
|
||||
n, err := writeProtectedGPSDay(ctx, db, group)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += n
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func writeProtectedGPSDay(ctx context.Context, db *sql.DB, group []*metricAgg) (int64, error) {
|
||||
first := group[0]
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
existing, selectedManual, err := lockGPSCandidates(ctx, tx, first.VIN, first.Date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var written int64
|
||||
for _, agg := range group {
|
||||
candidate := backfillCandidate(agg)
|
||||
if old, ok := existing[candidate.SourceKey]; ok {
|
||||
if isManualGPSCandidate(old) {
|
||||
continue
|
||||
}
|
||||
candidate = mergeProtectedGPSCandidate(old, candidate)
|
||||
}
|
||||
identity := stats.SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: candidate.SourceIP,
|
||||
SourceEndpoint: candidate.SourceEndpoint, SourceCode: agg.SourceCode, PlatformName: agg.PlatformName, SourceKind: agg.SourceKind}
|
||||
if candidate.QualityReason != stats.QualityReasonGPSCoordinate {
|
||||
if err := stats.UpsertDataSource(ctx, tx, identity, candidate.LatestEventTime); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := replaceBackfillCandidate(ctx, tx, candidate); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
// An explicitly selected imported report is a business override. Keep that
|
||||
// final result while still correcting the underlying automatic candidates.
|
||||
if !selectedManual {
|
||||
if err := stats.ProjectDailyMileage(ctx, tx, first.VIN, first.Date, envelope.ProtocolJT808); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func normalizeProtectedGPSDay(ctx context.Context, db *sql.DB, vin, date string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, manual, err := lockGPSCandidates(ctx, tx, vin, date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if manual {
|
||||
return tx.Commit()
|
||||
}
|
||||
if err := stats.NormalizePlatformSourceMileage(ctx, tx, vin, date, envelope.ProtocolJT808); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stats.ProjectDailyMileage(ctx, tx, vin, date, envelope.ProtocolJT808); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func isManualGPSCandidate(c stats.SourceMileageSample) bool {
|
||||
return strings.HasPrefix(c.QualityReason, "manual") || strings.HasPrefix(c.SourceIP, "manual") || strings.HasPrefix(c.SourceIP, "legacy-mysql")
|
||||
}
|
||||
|
||||
func backfillCandidate(agg *metricAgg) stats.SourceMileageSample {
|
||||
c := stats.SourceMileageSample{
|
||||
VIN: agg.VIN, StatDate: agg.Date, Protocol: agg.Protocol, SourceKey: agg.SourceKey,
|
||||
SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint), SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone, DeviceID: agg.DeviceID, PlatformName: agg.PlatformName,
|
||||
FirstTotalKM: agg.FirstKM, LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM),
|
||||
PureHydrogenMileageKM: agg.PureHydrogenMileageKM, SampleCount: agg.Count,
|
||||
PureHydrogenSampleCount: agg.PureHydrogenSampleCount,
|
||||
FirstEventTime: agg.FirstEventTime, LatestEventTime: agg.LatestEventTime,
|
||||
LatestPureHydrogenActive: agg.LatestPureHydrogenActive, LatestPureHydrogenModeKnown: agg.LatestPureHydrogenModeKnown,
|
||||
QualityStatus: agg.QualityStatus, QualityReason: agg.QualityReason,
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
func mergeProtectedGPSCandidate(old, next stats.SourceMileageSample) stats.SourceMileageSample {
|
||||
if next.QualityReason == stats.QualityReasonGPSCoordinate || old.QualityReason == stats.QualityReasonGPSCoordinate {
|
||||
if old.LatestEventTime.After(next.LatestEventTime) {
|
||||
return old
|
||||
}
|
||||
return next
|
||||
}
|
||||
if old.LatestEventTime.After(next.LatestEventTime) {
|
||||
slog.Info("preserved newer durable GPS end", "vin", next.VIN, "date", next.StatDate,
|
||||
"source_key", next.SourceKey, "raw_end", next.LatestEventTime, "durable_end", old.LatestEventTime)
|
||||
next.LatestEventTime, next.LatestTotalKM = old.LatestEventTime, old.LatestTotalKM
|
||||
next.SourceEndpoint = old.SourceEndpoint
|
||||
next.LatestPureHydrogenActive, next.LatestPureHydrogenModeKnown = old.LatestPureHydrogenActive, old.LatestPureHydrogenModeKnown
|
||||
}
|
||||
// Prefer the newest prior-day boundary; otherwise retain the earliest
|
||||
// current-day sample. Always move the timestamp and odometer together.
|
||||
if preferDurableGPSFirst(old.FirstEventTime, next.FirstEventTime, next.StatDate) {
|
||||
next.FirstEventTime, next.FirstTotalKM = old.FirstEventTime, old.FirstTotalKM
|
||||
}
|
||||
if old.SampleCount > next.SampleCount {
|
||||
next.SampleCount = old.SampleCount
|
||||
}
|
||||
if old.PureHydrogenSampleCount > next.PureHydrogenSampleCount {
|
||||
next.PureHydrogenSampleCount = old.PureHydrogenSampleCount
|
||||
}
|
||||
if old.PureHydrogenMileageKM > next.PureHydrogenMileageKM {
|
||||
next.PureHydrogenMileageKM = old.PureHydrogenMileageKM
|
||||
}
|
||||
next.DailyKM = stats.DailyMileageFromDayBoundary(next.FirstTotalKM, next.LatestTotalKM)
|
||||
next.QualityStatus = stats.QualityOK
|
||||
if next.FirstEventTime.Format("2006-01-02") < next.StatDate {
|
||||
next.QualityReason = stats.QualityReasonHistorical
|
||||
} else {
|
||||
next.QualityReason = stats.QualityReasonCurrentDayFirst
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&next)
|
||||
return next
|
||||
}
|
||||
|
||||
func preferDurableGPSFirst(old, next time.Time, date string) bool {
|
||||
if old.IsZero() {
|
||||
return false
|
||||
}
|
||||
if next.IsZero() {
|
||||
return true
|
||||
}
|
||||
oldBefore, nextBefore := old.Format("2006-01-02") < date, next.Format("2006-01-02") < date
|
||||
if oldBefore != nextBefore {
|
||||
return oldBefore
|
||||
}
|
||||
if oldBefore {
|
||||
return old.After(next)
|
||||
}
|
||||
return old.Before(next)
|
||||
}
|
||||
|
||||
func lockGPSCandidates(ctx context.Context, tx *sql.Tx, vin, date string) (map[string]stats.SourceMileageSample, bool, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT source_key,source_ip,COALESCE(source_endpoint,''),COALESCE(phone,''),COALESCE(device_id,''),COALESCE(platform_name,''),
|
||||
first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,pure_hydrogen_mileage_km,sample_count,pure_hydrogen_sample_count,
|
||||
first_event_time,latest_event_time,latest_pure_hydrogen_active,latest_pure_hydrogen_mode_known,quality_status,COALESCE(quality_reason,''),is_selected
|
||||
FROM vehicle_daily_mileage_source WHERE vin=? AND stat_date=? AND protocol='JT808' FOR UPDATE`, vin, date)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]stats.SourceMileageSample{}
|
||||
manual := false
|
||||
for rows.Next() {
|
||||
c := stats.SourceMileageSample{VIN: vin, StatDate: date, Protocol: envelope.ProtocolJT808}
|
||||
var first, last sql.NullTime
|
||||
var firstKM, lastKM sql.NullFloat64
|
||||
var selected bool
|
||||
if err := rows.Scan(&c.SourceKey, &c.SourceIP, &c.SourceEndpoint, &c.Phone, &c.DeviceID, &c.PlatformName,
|
||||
&firstKM, &lastKM, &c.DailyKM, &c.PureHydrogenMileageKM, &c.SampleCount, &c.PureHydrogenSampleCount,
|
||||
&first, &last, &c.LatestPureHydrogenActive, &c.LatestPureHydrogenModeKnown, &c.QualityStatus, &c.QualityReason, &selected); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
c.FirstEventTime, c.LatestEventTime = first.Time, last.Time
|
||||
c.FirstTotalKM, c.LatestTotalKM = firstKM.Float64, lastKM.Float64
|
||||
out[c.SourceKey] = c
|
||||
if selected && isManualGPSCandidate(c) {
|
||||
manual = true
|
||||
}
|
||||
}
|
||||
return out, manual, rows.Err()
|
||||
}
|
||||
|
||||
func replaceBackfillCandidate(ctx context.Context, tx *sql.Tx, c stats.SourceMileageSample) error {
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO vehicle_daily_mileage_source
|
||||
(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,device_id,platform_name,
|
||||
first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,pure_hydrogen_mileage_km,
|
||||
sample_count,pure_hydrogen_sample_count,first_event_time,latest_event_time,
|
||||
latest_pure_hydrogen_active,latest_pure_hydrogen_mode_known,quality_status,quality_reason)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE source_ip=VALUES(source_ip),source_endpoint=VALUES(source_endpoint),phone=VALUES(phone),device_id=VALUES(device_id),
|
||||
platform_name=VALUES(platform_name),first_total_mileage_km=VALUES(first_total_mileage_km),latest_total_mileage_km=VALUES(latest_total_mileage_km),
|
||||
daily_mileage_km=VALUES(daily_mileage_km),pure_hydrogen_mileage_km=VALUES(pure_hydrogen_mileage_km),
|
||||
sample_count=VALUES(sample_count),pure_hydrogen_sample_count=VALUES(pure_hydrogen_sample_count),
|
||||
first_event_time=VALUES(first_event_time),latest_event_time=VALUES(latest_event_time),
|
||||
latest_pure_hydrogen_active=VALUES(latest_pure_hydrogen_active),latest_pure_hydrogen_mode_known=VALUES(latest_pure_hydrogen_mode_known),
|
||||
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),updated_at=CURRENT_TIMESTAMP`,
|
||||
c.VIN, c.StatDate, string(c.Protocol), c.SourceKey, c.SourceIP, c.SourceEndpoint, c.Phone, c.DeviceID, c.PlatformName,
|
||||
c.FirstTotalKM, c.LatestTotalKM, c.DailyKM, c.PureHydrogenMileageKM, c.SampleCount, c.PureHydrogenSampleCount,
|
||||
c.FirstEventTime.Truncate(time.Second), c.LatestEventTime.Truncate(time.Second),
|
||||
c.LatestPureHydrogenActive, c.LatestPureHydrogenModeKnown, c.QualityStatus, c.QualityReason)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
// A lost session must never reconnect without its temporary shadow tables.
|
||||
type oneSessionConnector struct {
|
||||
driver.Connector
|
||||
used bool
|
||||
}
|
||||
|
||||
func (c *oneSessionConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
if c.used {
|
||||
return nil, errors.New("temporary-table integration session cannot reconnect")
|
||||
}
|
||||
c.used = true
|
||||
return c.Connector.Connect(ctx)
|
||||
}
|
||||
|
||||
func TestProtectedGPSWriteMySQLTemporaryTables(t *testing.T) {
|
||||
dsn := os.Getenv("BACKFILL_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set BACKFILL_TEST_MYSQL_DSN for isolated temporary-table integration")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal("invalid integration DSN")
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
t.Fatal("integration connector unavailable")
|
||||
}
|
||||
db := sql.OpenDB(&oneSessionConnector{Connector: connector})
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage", "vehicle_data_source"} {
|
||||
if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE gps_fixture_"+table+" LIKE "+table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE "+table+" LIKE gps_fixture_"+table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
loc := cfg.Loc
|
||||
prev := time.Date(2026, 10, 20, 23, 59, 0, 0, loc)
|
||||
end := time.Date(2026, 10, 21, 23, 59, 0, 0, loc)
|
||||
const vin = "LTESTGPSPROTECT001"
|
||||
key := "JT808:fixture@PLATFORM:g7s"
|
||||
if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source
|
||||
(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time,quality_status,quality_reason)
|
||||
VALUES (?,'2026-10-21','JT808',?,'1.2.3.4','1.2.3.4:1','fixture','G7s',100,120,20,200,?,?,'OK','historical_source_baseline'),
|
||||
(?,'2026-10-21','JT808','JT808:other@PLATFORM:other','2.3.4.5','2.3.4.5:2','other','Other',100,101,1,3,?,?,'OK','historical_source_baseline')`, vin, key, prev, end, vin, prev, end.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Keep the existing manual final selected. This exercises the protected
|
||||
// write path without MySQL's temporary-table self-join restriction in the
|
||||
// independent projection query (covered separately by replay verification).
|
||||
if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source (vin,stat_date,protocol,source_key,source_ip,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected)
|
||||
VALUES (?,'2026-10-21','JT808','manual-report','manual-import',88,1,'OK','manual_gps_report',1)`, vin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (?,'2026-10-21','JT808',0,88)`, vin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agg := &metricAgg{VIN: vin, Date: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: key, Phone: "fixture", SourceEndpoint: "1.2.3.4:3", SourceCode: "g7s", SourceKind: "PLATFORM", PlatformName: "G7s", FirstKM: 90, LatestKM: 115, Count: 100, FirstEventTime: prev.Add(-24 * time.Hour), LatestEventTime: end.Add(-time.Hour), QualityStatus: "OK", QualityReason: "historical_source_baseline"}
|
||||
read := func() []any {
|
||||
var first, last, daily float64
|
||||
var count int
|
||||
var firstTS, lastTS time.Time
|
||||
if err := db.QueryRow(`SELECT first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time FROM vehicle_daily_mileage_source WHERE vin=? AND source_key=?`, vin, key).Scan(&first, &last, &daily, &count, &firstTS, &lastTS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return []any{first, last, daily, count, firstTS, lastTS}
|
||||
}
|
||||
var before []any
|
||||
for run := 0; run < 2; run++ {
|
||||
if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := read()
|
||||
if got[0] != float64(100) || got[1] != float64(120) || got[2] != float64(20) || got[3] != 200 {
|
||||
t.Fatalf("durable data overwritten: %+v", got)
|
||||
}
|
||||
if run == 1 && !reflect.DeepEqual(before, got) {
|
||||
t.Fatalf("repeated backfill changed business values: before=%+v after=%+v", before, got)
|
||||
}
|
||||
before = got
|
||||
}
|
||||
var sources int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM vehicle_daily_mileage_source WHERE vin=?", vin).Scan(&sources); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sources != 3 {
|
||||
t.Fatalf("missing durable alternate source: %d", sources)
|
||||
}
|
||||
// Newer event with a lower counter wins by time, not MAX(odometer).
|
||||
agg.FirstKM, agg.FirstEventTime = 100, prev
|
||||
agg.LatestKM, agg.LatestEventTime = 119, end.Add(time.Second)
|
||||
if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := read(); got[1] != float64(119) || got[2] != float64(19) {
|
||||
t.Fatalf("newer event rejected: %+v", got)
|
||||
}
|
||||
agg.LatestKM, agg.LatestEventTime = 121, end.Add(2*time.Second)
|
||||
if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var final float64
|
||||
if err := db.QueryRow("SELECT daily_mileage_km FROM vehicle_daily_mileage WHERE vin=?", vin).Scan(&final); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final != 88 {
|
||||
t.Fatalf("manual final overwritten: %v", final)
|
||||
}
|
||||
t.Log("temporary-table scenarios passed: incomplete RAW, alternate source retention, repeated backfill, event ordering, manual final; production rows modified=0")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func TestDurableGPSBoundariesSurviveIncompleteRawAndRepeatedBackfill(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
previous := time.Date(2026, 10, 20, 23, 59, 0, 0, loc)
|
||||
last := time.Date(2026, 10, 21, 23, 59, 0, 0, loc)
|
||||
old := stats.SourceMileageSample{VIN: "VIN", StatDate: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:phone@PLATFORM:g7s", SourceEndpoint: "1.2.3.4:1", FirstEventTime: previous, FirstTotalKM: 100, LatestEventTime: last, LatestTotalKM: 120, DailyKM: 20, SampleCount: 200, QualityStatus: stats.QualityOK, QualityReason: stats.QualityReasonHistorical}
|
||||
partial := old
|
||||
partial.FirstEventTime, partial.FirstTotalKM = previous.Add(-24*time.Hour), 90
|
||||
partial.LatestEventTime, partial.LatestTotalKM = last.Add(-time.Hour), 115
|
||||
partial.SampleCount = 100
|
||||
for i := 0; i < 3; i++ {
|
||||
got := mergeProtectedGPSCandidate(old, partial)
|
||||
if !reflect.DeepEqual(got, old) {
|
||||
t.Fatalf("run %d replaced durable boundaries or double-counted: got=%+v old=%+v", i, got, old)
|
||||
}
|
||||
old = got
|
||||
}
|
||||
newer := partial
|
||||
newer.FirstEventTime, newer.FirstTotalKM = previous, 100
|
||||
newer.LatestEventTime, newer.LatestTotalKM = last.Add(time.Minute), 119
|
||||
got := mergeProtectedGPSCandidate(old, newer)
|
||||
if got.LatestTotalKM != 119 || got.DailyKM != 19 {
|
||||
t.Fatalf("event ordering incorrectly replaced with maximum odometer: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillCurrentDayFirstBoundaryRetainsEarliestEvent(t *testing.T) {
|
||||
day := time.Date(2026, 10, 21, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
if !preferDurableGPSFirst(day, day.Add(time.Hour), "2026-10-21") {
|
||||
t.Fatal("earliest current-day event lost")
|
||||
}
|
||||
if preferDurableGPSFirst(day.Add(-48*time.Hour), day.Add(-24*time.Hour), "2026-10-21") {
|
||||
t.Fatal("older previous-day baseline won")
|
||||
}
|
||||
}
|
||||
|
||||
func gpsLockedRows() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{"source_key", "source_ip", "endpoint", "phone", "device", "platform", "first_km", "last_km", "daily_km", "hydrogen_km", "count", "hydrogen_count", "first_time", "last_time", "active", "known", "status", "reason", "selected"})
|
||||
}
|
||||
|
||||
func TestGPSBackfillKeepsManualFinalAndRollsBackPartialWrites(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := time.Date(2026, 10, 21, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
agg := &metricAgg{VIN: "VIN", Date: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:phone@PLATFORM:g7s", SourceEndpoint: "1.2.3.4:123", FirstKM: 100, LatestKM: 120, FirstEventTime: stamp.Add(-24 * time.Hour), LatestEventTime: stamp, Count: 5, QualityStatus: stats.QualityOK, QualityReason: stats.QualityReasonHistorical}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs("VIN", "2026-10-21").WillReturnRows(gpsLockedRows().AddRow("manual-report", "manual-import", "", "", "", "G7s", nil, nil, 88, 0, 1, 0, nil, nil, false, false, "OK", "manual_gps_report", true))
|
||||
mock.ExpectExec("INSERT INTO vehicle_data_source").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
if fail {
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WillReturnError(errors.New("write failed"))
|
||||
mock.ExpectRollback()
|
||||
} else {
|
||||
mock.ExpectExec("(?s)INSERT INTO vehicle_daily_mileage_source.*sample_count=VALUES\\(sample_count\\)").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// No clearing other candidates, source-count increment or final
|
||||
// projection is permitted over a selected manual report.
|
||||
mock.ExpectCommit()
|
||||
}
|
||||
n, err := writeAggregates(context.Background(), db, map[string]*metricAgg{"target": agg}, 500)
|
||||
if fail && (err == nil || n != 0) {
|
||||
t.Fatalf("failed target committed: n=%d err=%v", n, err)
|
||||
}
|
||||
if !fail && (err != nil || n != 1) {
|
||||
t.Fatalf("manual override write: n=%d err=%v", n, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPSNormalizationDoesNotRewriteSelectedManualEvidence(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs("VIN", "2026-07-01").WillReturnRows(gpsLockedRows().AddRow("manual-report", "manual-import", "", "", "", "G7s", nil, nil, 88, 0, 1, 0, nil, nil, false, false, "OK", "manual_gps_report", true))
|
||||
// Do not normalize/delete the selected manual source, lose its selection
|
||||
// flag, or reproject over the manual final result.
|
||||
mock.ExpectCommit()
|
||||
if err := normalizeProtectedGPSDay(context.Background(), db, "VIN", "2026-07-01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
type backfillSourceID struct {
|
||||
protocol envelope.Protocol
|
||||
ip string
|
||||
}
|
||||
|
||||
// Resolve forwarding endpoints before grouping RAW or looking up durable daily
|
||||
// boundaries. Normalizing only after writing cannot repair an IP-key lookup
|
||||
// that has already missed the newer @PLATFORM boundary.
|
||||
func loadBackfillSourceCatalog(ctx context.Context, db *sql.DB) (map[backfillSourceID]stats.SourceIdentity, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT protocol, source_ip,
|
||||
COALESCE(source_code, ''), COALESCE(platform_name, ''), source_kind
|
||||
FROM vehicle_data_source WHERE protocol = 'JT808'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
catalog := map[backfillSourceID]stats.SourceIdentity{}
|
||||
for rows.Next() {
|
||||
var protocol string
|
||||
var source stats.SourceIdentity
|
||||
if err := rows.Scan(&protocol, &source.SourceIP, &source.SourceCode, &source.PlatformName, &source.SourceKind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source.Protocol = envelope.Protocol(strings.TrimSpace(protocol))
|
||||
source.SourceIP = stats.NormalizeSourceIP(source.SourceIP)
|
||||
source.SourceCode = strings.TrimSpace(source.SourceCode)
|
||||
source.PlatformName = strings.TrimSpace(source.PlatformName)
|
||||
source.SourceKind = strings.ToUpper(strings.TrimSpace(source.SourceKind))
|
||||
if source.SourceIP != "" {
|
||||
catalog[backfillSourceID{source.Protocol, source.SourceIP}] = source
|
||||
}
|
||||
}
|
||||
return catalog, rows.Err()
|
||||
}
|
||||
|
||||
func backfillSourceMetadata(cfg config, protocol envelope.Protocol, endpoint string) (string, string, string) {
|
||||
if source, ok := cfg.SourceCatalog[backfillSourceID{protocol, stats.NormalizeSourceIP(endpoint)}]; ok {
|
||||
return source.SourceCode, source.PlatformName, source.SourceKind
|
||||
}
|
||||
return knownPlatformSourceMetadata(protocol, endpoint)
|
||||
}
|
||||
|
||||
func mergeDailySourceRows(existing, incoming dailySourceLast) dailySourceLast {
|
||||
latest := existing
|
||||
if incoming.TS.After(existing.TS) {
|
||||
latest = incoming
|
||||
}
|
||||
if incoming.FirstTS.Before(existing.FirstTS) {
|
||||
latest.FirstTS, latest.FirstTotalKM = incoming.FirstTS, incoming.FirstTotalKM
|
||||
} else {
|
||||
latest.FirstTS, latest.FirstTotalKM = existing.FirstTS, existing.FirstTotalKM
|
||||
}
|
||||
latest.RawSampleCount = existing.RawSampleCount + incoming.RawSampleCount
|
||||
return latest
|
||||
}
|
||||
|
||||
func backfillVINPredicate(cfg config, column string) string {
|
||||
if len(cfg.VINs) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := make([]string, 0, len(cfg.VINs))
|
||||
for _, vin := range cfg.VINs {
|
||||
values = append(values, "'"+quote(vin)+"'")
|
||||
}
|
||||
return column + " IN (" + strings.Join(values, ",") + ")"
|
||||
}
|
||||
|
||||
func appendBackfillVINScope(query string, cfg config, column string) string {
|
||||
if predicate := backfillVINPredicate(cfg, column); predicate != "" {
|
||||
query += "\n AND " + predicate
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func logBackfillCandidates(aggregates map[string]*metricAgg) {
|
||||
keys := make([]string, 0, len(aggregates))
|
||||
for key := range aggregates {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
a := aggregates[key]
|
||||
slog.Info("backfill candidate", "vin", a.VIN, "date", a.Date, "protocol", a.Protocol,
|
||||
"source_key", a.SourceKey, "platform", a.PlatformName,
|
||||
"baseline_time", a.FirstEventTime.Format("2006-01-02 15:04:05"), "baseline_km", a.FirstKM,
|
||||
"end_time", a.LatestEventTime.Format("2006-01-02 15:04:05"), "end_km", a.LatestKM,
|
||||
"daily_km", fmt.Sprintf("%.3f", stats.DailyMileageFromDayBoundary(a.FirstKM, a.LatestKM)),
|
||||
"samples", a.Count, "quality", a.QualityStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func TestBackfillPlatformBoundarySurvivesRawGapAndRepeatedRuns(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
vin, terminal, ip, code string
|
||||
old, previous, end float64
|
||||
}{
|
||||
{"LMRKH9AC9R1004099", "64341233471", "115.159.85.149", "g7s", 62749.7, 64432.7, 65136},
|
||||
{"LMRKH9AC7R1004120", "64341233712", "115.159.85.149", "g7s", 51126.2, 52704.4, 53614.1},
|
||||
{"LMRKH9AC3R1004129", "64341233795", "122.152.221.156", "dongfang_beidou", 57481.7, 59072.7, 59434.6},
|
||||
} {
|
||||
t.Run(tc.vin, func(t *testing.T) {
|
||||
mysqlDB, mysql, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, td, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
mysql.ExpectQuery("SELECT protocol, source_ip").WillReturnRows(sqlmock.NewRows([]string{"protocol", "ip", "code", "name", "kind"}).
|
||||
AddRow("JT808", tc.ip, tc.code, tc.code, "PLATFORM"))
|
||||
catalog, err := loadBackfillSourceCatalog(context.Background(), mysqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
oldTS := time.Date(2026, 10, 16, 15, 26, 0, 0, loc)
|
||||
previousTS := time.Date(2026, 10, 18, 23, 59, 0, 0, loc)
|
||||
firstTS := time.Date(2026, 10, 19, 11, 0, 0, 0, loc)
|
||||
endTS := time.Date(2026, 10, 19, 23, 59, 0, 0, loc)
|
||||
parsed := func(km float64) string { return fmt.Sprintf(`{"jt808.location.total_mileage_km":%f}`, km) }
|
||||
key := "JT808:" + tc.terminal + "@PLATFORM:" + tc.code
|
||||
cfg := config{DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808},
|
||||
VINs: []string{tc.vin}, TDengineDatabase: "lingniu_vehicle_ts", Location: loc, SourceCatalog: catalog}
|
||||
// Run twice against the same durable October 18 boundary. A replay must
|
||||
// neither use October 16 RAW nor subtract today's already-written result.
|
||||
for run := 0; run < 2; run++ {
|
||||
td.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "last", "parsed", "raw", "count", "MAX_EVENT_MS"}).
|
||||
AddRow(tc.vin, tc.terminal, "", tc.ip+":1000", oldTS, parsed(tc.old), "", 10, oldTS.UnixMilli()))
|
||||
td.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "MIN_EVENT_MS", "MAX_EVENT_MS"}).
|
||||
AddRow(tc.vin, tc.terminal, "", tc.ip+":2000", firstTS, parsed(tc.previous+1), "", firstTS.Add(time.Hour), parsed(tc.previous+10), "", 100, firstTS.UnixMilli(), firstTS.Add(time.Hour).UnixMilli()).
|
||||
AddRow(tc.vin, tc.terminal, "", tc.ip+":3000", firstTS.Add(2*time.Hour), parsed(tc.previous+11), "", endTS, parsed(tc.end), "", 200, firstTS.Add(2*time.Hour).UnixMilli(), endTS.UnixMilli()))
|
||||
mysql.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source").
|
||||
WithArgs(tc.vin, "2026-10-19", "JT808", key).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(tc.previous, previousTS))
|
||||
aggregates, err := buildLastDiffAggregates(context.Background(), mysqlDB, tdDB, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := aggregates[tc.vin+"|2026-10-19|JT808|"+key]
|
||||
if len(aggregates) != 1 || a == nil || a.FirstKM != tc.previous || !a.FirstEventTime.Equal(previousTS) || a.Count != 300 {
|
||||
t.Fatalf("run %d: wrong boundary or connection grouping: %+v", run, a)
|
||||
}
|
||||
if math.Abs((a.LatestKM-a.FirstKM)-(tc.end-tc.previous)) > 0.0001 || a.SourceCode != tc.code {
|
||||
t.Fatalf("run %d: recounted historical mileage: %+v", run, a)
|
||||
}
|
||||
}
|
||||
if err := mysql.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := td.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillSourceCatalogPreservesIndependentAndUnknownCounters(t *testing.T) {
|
||||
cfg := config{SourceCatalog: map[backfillSourceID]stats.SourceIdentity{
|
||||
{envelope.ProtocolJT808, "1.2.3.4"}: {SourceCode: "registered", SourceKind: "PLATFORM"},
|
||||
{envelope.ProtocolJT808, "2.3.4.5"}: {SourceKind: "DIRECT"},
|
||||
}}
|
||||
key := func(protocol envelope.Protocol, terminal, endpoint string) string {
|
||||
code, _, kind := backfillSourceMetadata(cfg, protocol, endpoint)
|
||||
return normalizedSourceKeyForSource(string(protocol), terminal, "", endpoint, kind, code)
|
||||
}
|
||||
for _, tc := range []struct{ terminal, endpoint, want string }{
|
||||
{"terminal-a", "1.2.3.4:123", "JT808:terminal-a@PLATFORM:registered"},
|
||||
{"terminal-b", "1.2.3.4:456", "JT808:terminal-b@PLATFORM:registered"},
|
||||
{"terminal-a", "2.3.4.5:123", "JT808:terminal-a@DIRECT"},
|
||||
{"terminal-a", "3.4.5.6:123", "JT808:terminal-a@3.4.5.6"},
|
||||
} {
|
||||
if got := key(envelope.ProtocolJT808, tc.terminal, tc.endpoint); got != tc.want {
|
||||
t.Fatalf("key=%s want=%s", got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := key(envelope.ProtocolGB32960, "terminal-a", "1.2.3.4:123"); strings.Contains(got, "PLATFORM") {
|
||||
t.Fatalf("JT808 metadata leaked into GB32960: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillSourceCatalogFailureIsNotIgnored(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT protocol, source_ip").WillReturnError(errors.New("database unavailable"))
|
||||
if _, err := loadBackfillSourceCatalog(context.Background(), db); err == nil {
|
||||
t.Fatal("missing source catalog must fail before recomputation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillVINScopeIncludesCleanupAndRealtimeFallback(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
cfg := config{VINs: []string{"ONLY-THIS-VIN"}, DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}}
|
||||
mock.ExpectQuery("(?s)FROM vehicle_daily_mileage_source.*AND vin IN \\('ONLY-THIS-VIN'\\).*GROUP BY").
|
||||
WithArgs(cfg.DateFrom, cfg.DateTo, stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "count"}))
|
||||
if _, _, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, cfg, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectQuery("(?s)FROM vehicle_realtime_location.*AND l.vin IN \\('ONLY-THIS-VIN'\\)").
|
||||
WithArgs("YUTONG_MQTT", cfg.DateFrom, cfg.DateFrom).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "time", "total"}))
|
||||
if _, err := queryRealtimeLocationLastRows(context.Background(), db, cfg, envelope.ProtocolYutongMQTT, cfg.DateFrom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillScopedNormalizationDoesNotScanOtherVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
vin, date := "ONLY-THIS-VIN", "2026-10-19"
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs(vin, date).WillReturnRows(gpsLockedRows())
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("DELETE s").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WithArgs(vin, date, "JT808", int64(2500)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
_, err = normalizeHistoricalPlatformSources(context.Background(), db, config{VINs: []string{vin}, DateFrom: date, DateTo: date, Protocols: []envelope.Protocol{envelope.ProtocolJT808}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDailySourceRowsIsIndependentOfConnectionOrder(t *testing.T) {
|
||||
first := dailySourceLast{FirstTS: time.Unix(100, 0), TS: time.Unix(200, 0), FirstTotalKM: 10, TotalKM: 20, RawSampleCount: 4}
|
||||
last := dailySourceLast{FirstTS: time.Unix(300, 0), TS: time.Unix(400, 0), FirstTotalKM: 30, TotalKM: 40, RawSampleCount: 6}
|
||||
for _, merged := range []dailySourceLast{mergeDailySourceRows(first, last), mergeDailySourceRows(last, first)} {
|
||||
if merged.FirstTotalKM != 10 || merged.TotalKM != 40 || merged.RawSampleCount != 10 {
|
||||
t.Fatalf("lost connection boundary: %+v", merged)
|
||||
}
|
||||
}
|
||||
// Scope quoting is shared by SQL paths, including escaped input.
|
||||
if got := backfillVINPredicate(config{VINs: []string{"A'B"}}, "vin"); got != "vin IN ('A''B')" {
|
||||
t.Fatalf("unexpected predicate: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
|
||||
)
|
||||
|
||||
func TestFeichiCustom80UsesConnectionLoginAndSurvivesCanonicalRaw(t *testing.T) {
|
||||
payload, _ := hex.DecodeString("3B1428271014282710024E01560000")
|
||||
body := []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0x80, 0, 15}
|
||||
body = append(body, payload...)
|
||||
body = append(body, 0x80, 0, 3, 0x05, 0x80, 0x02)
|
||||
body = append(body, 0x05, 0, 0x07, 0x36, 0x50, 0x40, 0x01, 0xd2, 0x4f, 0)
|
||||
frame := buildGBFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", body)
|
||||
for _, tc := range []struct {
|
||||
account string
|
||||
password string
|
||||
wantCustom bool
|
||||
}{
|
||||
{"Feichi", "test-secret", true},
|
||||
{"Feichi", "wrong-secret", false},
|
||||
{"Hyundai", "test-secret", false},
|
||||
{"YueJin", "test-secret", false},
|
||||
} {
|
||||
t.Run(tc.account+"/"+tc.password, func(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
server := newTestServer(t, TCPProtocol{
|
||||
Protocol: envelope.ProtocolGB32960, Addr: ":0",
|
||||
Extract: gb32960.ExtractFrames, Parse: gb32960.ParseFrame,
|
||||
ParseForPlatform: gb32960.ParseFrameForPlatform,
|
||||
Authenticate: authentication.NewGB32960PlatformAuthenticator(authentication.ModeEnforce, map[string][]string{
|
||||
"Feichi": {"test-secret"}, "Hyundai": {"test-secret"}, "YueJin": {"test-secret"},
|
||||
}),
|
||||
}, sink)
|
||||
state := &connectionState{}
|
||||
login := []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0, 1}
|
||||
login = append(login, fixedASCIIBytes(tc.account, 12)...)
|
||||
login = append(login, fixedASCIIBytes(tc.password, 20)...)
|
||||
login = append(login, 1)
|
||||
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "", login), "183.239.48.25:2147", state)
|
||||
server.handleFrame(context.Background(), nil, frame, "183.239.48.25:2147", state)
|
||||
if len(sink.raw) != 2 {
|
||||
t.Fatalf("raw frames=%d", len(sink.raw))
|
||||
}
|
||||
raw := sink.raw[1]
|
||||
if raw.RawHex != strings.ToUpper(hex.EncodeToString(frame)) || len(raw.Parsed) != 0 {
|
||||
t.Fatal("canonical whole RAW frame was changed or contains duplicate tree")
|
||||
}
|
||||
prefix := "gb32960.feichi_custom_80.blocks."
|
||||
_, custom := raw.ParsedFields[prefix+"block_1.payload_hex"]
|
||||
if custom != tc.wantCustom {
|
||||
t.Fatalf("account=%s wantCustom=%v fields=%#v", tc.account, tc.wantCustom, raw.ParsedFields)
|
||||
}
|
||||
if !tc.wantCustom {
|
||||
if raw.ParsedFields["gb32960.gd_fc_demo_extension.declared_length"] != "15" {
|
||||
t.Fatal("legacy decoder was not retained")
|
||||
}
|
||||
return
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
prefix + "block_1.declared_length": "15",
|
||||
prefix + "block_1.actual_length": "15",
|
||||
prefix + "block_1.payload_hex": "3B1428271014282710024E01560000",
|
||||
prefix + "block_1.raw_hex": "80000F3B1428271014282710024E01560000",
|
||||
prefix + "block_2.payload_hex": "058002",
|
||||
prefix + "block_2.raw_hex": "800003058002",
|
||||
"gb32960.position.longitude": "121",
|
||||
"gb32960.position.latitude": "30.56",
|
||||
} {
|
||||
if got := fmt.Sprint(raw.ParsedFields[key]); got != want {
|
||||
t.Fatalf("%s=%s want=%s", key, got, want)
|
||||
}
|
||||
}
|
||||
if raw.PlatformName != "Feichi" || raw.ParseStatus != envelope.ParseOK {
|
||||
t.Fatalf("platform=%q status=%s", raw.PlatformName, raw.ParseStatus)
|
||||
}
|
||||
// A fresh TCP connection does not inherit the previous account.
|
||||
server.handleFrame(context.Background(), nil, frame, "183.239.48.25:9999", &connectionState{})
|
||||
if _, exists := sink.raw[2].ParsedFields[prefix+"block_1.payload_hex"]; exists {
|
||||
t.Fatal("Feichi account leaked into a fresh connection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeichiCustom80TruncationIsPreservedThroughRawProjection(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
server := newTestServer(t, TCPProtocol{
|
||||
Protocol: envelope.ProtocolGB32960, Addr: ":0",
|
||||
Extract: gb32960.ExtractFrames, Parse: gb32960.ParseFrame,
|
||||
ParseForPlatform: gb32960.ParseFrameForPlatform,
|
||||
}, sink)
|
||||
frame := buildGBFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0x80, 0, 15, 0x3b, 0x14})
|
||||
server.handleFrame(context.Background(), nil, frame, "183.239.48.25:2147", &connectionState{platformName: "Feichi"})
|
||||
raw := sink.raw[0]
|
||||
for key, want := range map[string]string{
|
||||
"gb32960.feichi_custom_80.blocks.block_1.declared_length": "15",
|
||||
"gb32960.feichi_custom_80.blocks.block_1.actual_length": "2",
|
||||
"gb32960.feichi_custom_80.blocks.block_1.payload_hex": "3B14",
|
||||
"gb32960.feichi_custom_80.blocks.block_1.raw_hex": "80000F3B14",
|
||||
"gb32960.feichi_custom_80.blocks.block_1.parse_error": "truncated_payload",
|
||||
} {
|
||||
if got := fmt.Sprint(raw.ParsedFields[key]); got != want {
|
||||
t.Fatalf("%s=%s want=%s", key, got, want)
|
||||
}
|
||||
}
|
||||
if raw.ParseStatus != envelope.ParsePartial || raw.RawHex != strings.ToUpper(hex.EncodeToString(frame)) {
|
||||
t.Fatalf("status=%s raw=%s", raw.ParseStatus, raw.RawHex)
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,18 @@ type FrameExtractor func([]byte) (frames [][]byte, remainder []byte, err error)
|
||||
|
||||
type FrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error)
|
||||
|
||||
type PlatformFrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint, platformAccount string) (envelope.FrameEnvelope, error)
|
||||
|
||||
type FrameResponder func(raw []byte, env envelope.FrameEnvelope) (response []byte, ok bool, err error)
|
||||
|
||||
type TCPProtocol struct {
|
||||
Protocol envelope.Protocol
|
||||
Addr string
|
||||
Extract FrameExtractor
|
||||
Parse FrameParser
|
||||
Authenticate authentication.Authenticator
|
||||
Respond FrameResponder
|
||||
Protocol envelope.Protocol
|
||||
Addr string
|
||||
Extract FrameExtractor
|
||||
Parse FrameParser
|
||||
ParseForPlatform PlatformFrameParser
|
||||
Authenticate authentication.Authenticator
|
||||
Respond FrameResponder
|
||||
}
|
||||
|
||||
type connectionState struct {
|
||||
@@ -283,7 +286,17 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
|
||||
defer cancelFrame()
|
||||
|
||||
receivedAtMS := time.Now().UnixMilli()
|
||||
env, err := s.protocol.Parse(raw, receivedAtMS, source)
|
||||
var env envelope.FrameEnvelope
|
||||
var err error
|
||||
if s.protocol.ParseForPlatform != nil {
|
||||
platformAccount := ""
|
||||
if state != nil {
|
||||
platformAccount = state.platformName
|
||||
}
|
||||
env, err = s.protocol.ParseForPlatform(raw, receivedAtMS, source, platformAccount)
|
||||
} else {
|
||||
env, err = s.protocol.Parse(raw, receivedAtMS, source)
|
||||
}
|
||||
if err != nil {
|
||||
env = envelope.FrameEnvelope{
|
||||
Protocol: s.protocol.Protocol,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package gb32960
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
const feichiCustom80Payload = "3B1428271014282710024E01560000"
|
||||
|
||||
func TestFeichiCustom80UsesDeclaredLengthAndContinuesWithStandardData(t *testing.T) {
|
||||
for _, command := range []byte{0x02, 0x03} {
|
||||
for _, length := range []int{0, 1, 9, 15, 32, 513} {
|
||||
t.Run(fmt.Sprintf("command_%02X_length_%d", command, length), func(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte{0x80, 0x05, 0x02, 0x23, 0x23}, (length+4)/5)[:length]
|
||||
if length == 15 {
|
||||
payload, _ = hex.DecodeString(feichiCustom80Payload)
|
||||
}
|
||||
body := []byte{0x1a, 0x0a, 0x17, 0x0a, 0x00, 0x00, 0x80, byte(length >> 8), byte(length)}
|
||||
body = append(body, payload...)
|
||||
body = append(body, feichiTestPositionUnit()...)
|
||||
body = append(body, 0x34, 0, 0, 0, 0, 0, 0, 0x2b)
|
||||
frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", body)
|
||||
env, err := ParseFrameForPlatform(frame, 1234, "183.239.48.25:2147", "Feichi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.ParseStatus != envelope.ParseOK || env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) {
|
||||
t.Fatalf("status=%s raw=%s", env.ParseStatus, env.RawHex)
|
||||
}
|
||||
units := env.Parsed["data_units"].([]map[string]any)
|
||||
if len(units) != 3 || units[0]["name"] != "feichi_custom_80" || units[1]["name"] != "position" || units[2]["name"] != "gd_fc_vehicle_info" {
|
||||
t.Fatalf("wrong unit boundaries: %#v", units)
|
||||
}
|
||||
block := feichiBlock(t, units[0], 1)
|
||||
if block["declared_length"] != length || block["actual_length"] != length || block["payload_hex"] != strings.ToUpper(hex.EncodeToString(payload)) {
|
||||
t.Fatalf("block=%#v", block)
|
||||
}
|
||||
if want := fmt.Sprintf("80%04X%X", length, payload); block["raw_hex"] != want {
|
||||
t.Fatalf("raw=%v want=%s", block["raw_hex"], want)
|
||||
}
|
||||
assertFloatField(t, env, envelope.FieldLongitude, 121)
|
||||
assertFloatField(t, env, envelope.FieldLatitude, 30.56)
|
||||
assertFloatField(t, env, "gd_fc_vehicle_hydrogen_mass_kg", 4.3)
|
||||
if _, exists := env.Fields["gd_fc_demo_stack_temp_c"]; exists {
|
||||
t.Fatal("Feichi opaque bytes must not become Guangdong measurement fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeichiCustom80TruncationKeepsEveryReceivedByte(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
error string
|
||||
payload string
|
||||
}{
|
||||
{"missing_length", nil, "truncated_length", ""},
|
||||
{"partial_length", []byte{0}, "truncated_length", ""},
|
||||
{"short_payload", []byte{0, 15, 0x3b, 0x14, 0x28}, "truncated_payload", "3B1428"},
|
||||
{"oversized_length", []byte{0xff, 0xff, 0x80}, "truncated_payload", "80"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...)
|
||||
body = append(body, 0x80)
|
||||
body = append(body, tc.data...)
|
||||
frame := buildFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", body)
|
||||
env, err := ParseFrameForPlatform(frame, 1234, "source", "Feichi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.ParseStatus != envelope.ParsePartial || !strings.Contains(env.ParseError, tc.error) {
|
||||
t.Fatalf("status=%s error=%q", env.ParseStatus, env.ParseError)
|
||||
}
|
||||
if env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) {
|
||||
t.Fatal("whole RAW frame was changed")
|
||||
}
|
||||
units := env.Parsed["data_units"].([]map[string]any)
|
||||
if len(units) != 2 || units[0]["name"] != "position" {
|
||||
t.Fatalf("units=%#v", units)
|
||||
}
|
||||
assertFloatField(t, env, envelope.FieldLongitude, 121)
|
||||
block := feichiBlock(t, units[1], 1)
|
||||
if block["raw_hex"] != fmt.Sprintf("80%X", tc.data) || block["payload_hex"] != tc.payload || block["parse_error"] != tc.error {
|
||||
t.Fatalf("received bytes not preserved: %#v", block)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformParserPreservesExisting32960Accounts(t *testing.T) {
|
||||
frame, err := hex.DecodeString(realFuelCellFrame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := ParseFrame(frame, 1234, "same-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, account := range []string{"", "Hyundai", "YueJin", "GuangJiaoTou", "feichi", "Feichi-other"} {
|
||||
t.Run(account, func(t *testing.T) {
|
||||
got, err := ParseFrameForPlatform(frame, 1234, "same-source", account)
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("legacy account changed: account=%q err=%v", account, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
assertIntField(t, want, "gd_fc_demo_stack_temp_c", 65)
|
||||
assertFloatField(t, want, "gd_fc_vehicle_hydrogen_mass_kg", 4.3)
|
||||
}
|
||||
|
||||
func TestFeichiFramesWithoutCustom80KeepExistingResults(t *testing.T) {
|
||||
for _, command := range []byte{0x02, 0x03} {
|
||||
frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...))
|
||||
want, err := ParseFrame(frame, 1234, "same-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseFrameForPlatform(frame, 1234, "same-source", "Feichi")
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("standard Feichi frame changed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func feichiBlock(t *testing.T, unit map[string]any, index int) map[string]any {
|
||||
t.Helper()
|
||||
return unit["value"].(map[string]any)["blocks"].(map[string]any)[fmt.Sprintf("block_%d", index)].(map[string]any)
|
||||
}
|
||||
|
||||
func feichiTestPositionUnit() []byte {
|
||||
return []byte{0x05, 0x00, 0x07, 0x36, 0x50, 0x40, 0x01, 0xd2, 0x4f, 0x00}
|
||||
}
|
||||
@@ -51,6 +51,17 @@ func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error)
|
||||
}
|
||||
|
||||
func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
return parseFrame(raw, receivedAtMS, sourceEndpoint, false)
|
||||
}
|
||||
|
||||
// ParseFrameForPlatform selects Feichi's opaque, length-prefixed 0x80 format
|
||||
// using the account established by this TCP connection's platform login.
|
||||
// All other accounts retain the existing Guangdong extension decoder.
|
||||
func ParseFrameForPlatform(raw []byte, receivedAtMS int64, sourceEndpoint, platformAccount string) (envelope.FrameEnvelope, error) {
|
||||
return parseFrame(raw, receivedAtMS, sourceEndpoint, strings.TrimSpace(platformAccount) == "Feichi")
|
||||
}
|
||||
|
||||
func parseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string, feichiCustom80 bool) (envelope.FrameEnvelope, error) {
|
||||
if len(raw) < minFrameLen {
|
||||
return envelope.FrameEnvelope{}, fmt.Errorf("%w: len=%d", ErrFrameTooShort, len(raw))
|
||||
}
|
||||
@@ -86,7 +97,7 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope
|
||||
eventTimeMS := receivedAtMS
|
||||
|
||||
if command == 0x02 || command == 0x03 {
|
||||
eventTime, units := parseDataBody(version, body, fields)
|
||||
eventTime, units := parseDataBodyWithCustom80(version, body, fields, feichiCustom80)
|
||||
if !eventTime.IsZero() {
|
||||
eventTimeMS = eventTime.UnixMilli()
|
||||
fields["device_time"] = eventTime.Format(time.RFC3339)
|
||||
@@ -117,17 +128,33 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope
|
||||
Fields: fields,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
if feichiCustom80 {
|
||||
if units, ok := parsed["data_units"].([]map[string]any); ok {
|
||||
for _, unit := range units {
|
||||
if unit["name"] == "feichi_custom_80" && unit["error"] != nil {
|
||||
env.ParseStatus = envelope.ParsePartial
|
||||
env.ParseError = "Feichi 0x80: " + fmt.Sprint(unit["error"])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
env.EventID = env.StableEventID()
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func parseDataBody(version string, body []byte, fields map[string]any) (time.Time, []map[string]any) {
|
||||
return parseDataBodyWithCustom80(version, body, fields, false)
|
||||
}
|
||||
|
||||
func parseDataBodyWithCustom80(version string, body []byte, fields map[string]any, feichiCustom80 bool) (time.Time, []map[string]any) {
|
||||
if len(body) < 6 {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
eventTime := parseGBTime(body[:6])
|
||||
cursor := 6
|
||||
var units []map[string]any
|
||||
custom80Blocks := 0
|
||||
for cursor < len(body) {
|
||||
unitType := body[cursor]
|
||||
cursor++
|
||||
@@ -293,6 +320,16 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
|
||||
fields["gd_fc_vehicle_hydrogen_mass_kg"] = unit["hydrogen_mass_kg"]
|
||||
cursor += 7
|
||||
case 0x80:
|
||||
if feichiCustom80 {
|
||||
custom80Blocks++
|
||||
unit, size := parseFeichiCustom80Data(body[cursor:], custom80Blocks)
|
||||
units = append(units, unit)
|
||||
cursor += size
|
||||
if unit["error"] != nil {
|
||||
return eventTime, units
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(body[cursor:]) < 11 {
|
||||
units = append(units, map[string]any{"type": "0x80", "error": "truncated"})
|
||||
return eventTime, units
|
||||
@@ -323,6 +360,43 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
|
||||
return eventTime, units
|
||||
}
|
||||
|
||||
func parseFeichiCustom80Data(data []byte, blockNumber int) (map[string]any, int) {
|
||||
size := len(data)
|
||||
lengthSize := min(2, len(data))
|
||||
payload := []byte(nil)
|
||||
block := map[string]any{
|
||||
"length_hex": strings.ToUpper(hex.EncodeToString(data[:lengthSize])),
|
||||
}
|
||||
parseError := ""
|
||||
if len(data) < 2 {
|
||||
parseError = "truncated_length"
|
||||
} else {
|
||||
declared := int(binary.BigEndian.Uint16(data[:2]))
|
||||
block["declared_length"] = declared
|
||||
size = min(2+declared, len(data))
|
||||
payload = data[2:size]
|
||||
if size < 2+declared {
|
||||
parseError = "truncated_payload"
|
||||
}
|
||||
}
|
||||
block["actual_length"] = len(payload)
|
||||
block["payload_hex"] = strings.ToUpper(hex.EncodeToString(payload))
|
||||
block["raw_hex"] = "80" + strings.ToUpper(hex.EncodeToString(data[:size]))
|
||||
unit := map[string]any{
|
||||
"type": "0x80",
|
||||
"name": "feichi_custom_80",
|
||||
"value": map[string]any{
|
||||
// Distinct keys preserve repeated 0x80 blocks in flattened RAW fields.
|
||||
"blocks": map[string]any{fmt.Sprintf("block_%d", blockNumber): block},
|
||||
},
|
||||
}
|
||||
if parseError != "" {
|
||||
unit["error"] = parseError
|
||||
block["parse_error"] = parseError
|
||||
}
|
||||
return unit, size
|
||||
}
|
||||
|
||||
func frameLength(frameStart []byte, bodyLen int) int {
|
||||
standardLen := headerLen + bodyLen + 1
|
||||
if len(frameStart) < headerLen || frameStart[2] != 0x05 || bodyLen != 41 {
|
||||
|
||||
@@ -49,6 +49,7 @@ type Writer struct {
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
sourceIdentities map[string]SourceIdentity
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
@@ -355,6 +356,7 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
result.SamplesSkippedMissingFields = 1
|
||||
return result, nil
|
||||
}
|
||||
env = w.withSourceIdentity(env)
|
||||
seenAt := w.sourceSeenAt(env)
|
||||
w.maybeCleanupCaches(seenAt)
|
||||
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
// ReloadSourceIdentities aligns live JT808 candidates with the durable platform
|
||||
// keys used by backfill and daily statistics. A snapshot avoids a database
|
||||
// lookup per frame; a failed refresh leaves the last successful snapshot intact.
|
||||
func (w *Writer) ReloadSourceIdentities(ctx context.Context) (int, error) {
|
||||
rows, err := w.query.QueryContext(ctx, `SELECT source_ip,COALESCE(source_code,''),COALESCE(platform_name,''),source_kind
|
||||
FROM vehicle_data_source WHERE protocol='JT808'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
next := map[string]SourceIdentity{}
|
||||
for rows.Next() {
|
||||
var source SourceIdentity
|
||||
if err := rows.Scan(&source.SourceIP, &source.SourceCode, &source.PlatformName, &source.SourceKind); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
source.Protocol = envelope.ProtocolJT808
|
||||
source.SourceIP = NormalizeSourceIP(source.SourceIP)
|
||||
source.SourceCode = strings.TrimSpace(source.SourceCode)
|
||||
source.SourceKind = strings.ToUpper(strings.TrimSpace(source.SourceKind))
|
||||
if source.SourceIP != "" && ((source.SourceKind == "PLATFORM" && source.SourceCode != "") || source.SourceKind == "DIRECT") {
|
||||
next[source.SourceIP] = source
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.sourceIdentities = next
|
||||
w.mu.Unlock()
|
||||
return len(next), nil
|
||||
}
|
||||
|
||||
func (w *Writer) withSourceIdentity(env envelope.FrameEnvelope) envelope.FrameEnvelope {
|
||||
if env.Protocol != envelope.ProtocolJT808 {
|
||||
return env
|
||||
}
|
||||
ip := NormalizeSourceIP(env.SourceEndpoint)
|
||||
w.mu.Lock()
|
||||
source, found := w.sourceIdentities[ip]
|
||||
w.mu.Unlock()
|
||||
if found {
|
||||
env.SourceCode = source.SourceCode
|
||||
env.SourceKind = source.SourceKind
|
||||
if strings.TrimSpace(source.PlatformName) != "" {
|
||||
env.PlatformName = source.PlatformName
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestWriterResolvesForwardingIPBeforeDurableBaselineLookup(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
writer := NewWriter(db, loc)
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("115.159.85.149", "g7s", "G7s", "PLATFORM"))
|
||||
if _, err := writer.ReloadSourceIdentities(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vin, phone, key := "LMRKH9AC7R1004120", "64341233712", "JT808:64341233712@PLATFORM:g7s"
|
||||
previous := time.Date(2026, 10, 18, 23, 59, 49, 0, loc)
|
||||
current := time.Date(2026, 10, 19, 12, 0, 0, 0, loc)
|
||||
mock.ExpectExec("INSERT INTO vehicle_data_source").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808", key).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(52704.4, previous))
|
||||
mock.ExpectBegin()
|
||||
args := []driver.Value{vin, "2026-10-19", "JT808", key}
|
||||
for len(args) < 21 {
|
||||
args = append(args, sqlmock.AnyArg())
|
||||
}
|
||||
args[9] = 52704.4
|
||||
args[10] = 52714.4
|
||||
args[11] = 10.0
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(args...).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("DELETE s").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
fields := map[string]any{"jt808.location.total_mileage_km": 52714.4}
|
||||
_, err = writer.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: vin, Phone: phone, SourceEndpoint: "115.159.85.149:12345", Fields: fields, ParsedFields: fields, EventTimeMS: current.UnixMilli(), ReceivedAtMS: current.UnixMilli(), ParseStatus: envelope.ParseOK})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceIdentityRefreshRetainsSnapshotOnFailureAndDoesNotMixProtocols(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
writer := NewWriter(db, time.UTC)
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("1.2.3.4", "g7s", "G7s", "PLATFORM").AddRow("2.3.4.5", "", "", "DIRECT").AddRow("3.4.5.6", "", "", "UNKNOWN"))
|
||||
if n, err := writer.ReloadSourceIdentities(context.Background()); err != nil || n != 2 {
|
||||
t.Fatalf("loaded=%d err=%v", n, err)
|
||||
}
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnError(errors.New("temporary database failure"))
|
||||
if _, err := writer.ReloadSourceIdentities(context.Background()); err == nil {
|
||||
t.Fatal("expected refresh failure")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
protocol envelope.Protocol
|
||||
endpoint, kind, code string
|
||||
}{
|
||||
{envelope.ProtocolJT808, "1.2.3.4:10", "PLATFORM", "g7s"},
|
||||
{envelope.ProtocolJT808, "2.3.4.5:20", "DIRECT", ""},
|
||||
{envelope.ProtocolJT808, "3.4.5.6:30", "", ""},
|
||||
{envelope.ProtocolGB32960, "1.2.3.4:40", "", ""},
|
||||
} {
|
||||
got := writer.withSourceIdentity(envelope.FrameEnvelope{Protocol: tc.protocol, SourceEndpoint: tc.endpoint})
|
||||
if got.SourceCode != tc.code || got.SourceKind != tc.kind {
|
||||
t.Fatalf("unexpected source metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,25 +282,10 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
|
||||
if strings.TrimSpace(statDate) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
||||
vins, err := PlatformSourceMileageLegacyVINs(ctx, db, statDate, protocol)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vins []string
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(vin) != "" {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
normalized := 0
|
||||
for _, vin := range vins {
|
||||
@@ -315,6 +300,27 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// PlatformSourceMileageLegacyVINs lets batch callers apply their own atomic
|
||||
// projection policy while sharing the exact registry/terminal alias rules.
|
||||
func PlatformSourceMileageLegacyVINs(ctx context.Context, db Queryer, statDate string, protocol envelope.Protocol) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var vins []string
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(vin) != "" {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
return vins, rows.Err()
|
||||
}
|
||||
|
||||
func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool {
|
||||
return strings.Contains(sample.SourceKey, platformSourceKeyPrefix)
|
||||
}
|
||||
@@ -441,7 +447,20 @@ const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + up
|
||||
|
||||
const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
// Prefer the newest boundary before this natural day. If no historical
|
||||
// boundary exists, retain the earliest sample inside the day. Select the
|
||||
// odometer and its timestamp from the same row so stale forwarding aliases
|
||||
// cannot restore an older baseline after a repair or a cache refresh.
|
||||
const normalizePlatformFirstOrderSQL = `CASE
|
||||
WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN 0
|
||||
WHEN s.first_event_time IS NOT NULL THEN 1
|
||||
ELSE 2 END,
|
||||
CASE WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN s.first_event_time ELSE NULL END DESC,
|
||||
s.first_event_time ASC, s.updated_at DESC`
|
||||
|
||||
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
const normalizePlatformFirstEventSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_event_time ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DATETIME)`
|
||||
|
||||
const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
@@ -454,7 +473,7 @@ const normalizePlatformDailySQL = `CASE
|
||||
ELSE ` + normalizePlatformDeltaSQL + `
|
||||
END`
|
||||
|
||||
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))`
|
||||
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(` + normalizePlatformFirstEventSQL + `)))`
|
||||
|
||||
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
@@ -537,7 +556,7 @@ SELECT
|
||||
SUM(s.pure_hydrogen_mileage_km) AS pure_hydrogen_mileage_km,
|
||||
SUM(s.sample_count) AS sample_count,
|
||||
SUM(s.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count,
|
||||
MIN(s.first_event_time) AS first_event_time,
|
||||
` + normalizePlatformFirstEventSQL + ` AS first_event_time,
|
||||
MAX(s.latest_event_time) AS latest_event_time,
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_active ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_active,
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_mode_known ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_mode_known,
|
||||
@@ -548,7 +567,7 @@ SELECT
|
||||
CASE
|
||||
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
|
||||
WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
|
||||
WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
|
||||
WHEN ` + normalizePlatformFirstEventSQL + ` < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
|
||||
THEN '` + QualityReasonHistorical + `'
|
||||
ELSE '` + QualityReasonCurrentDayFirst + `'
|
||||
END AS quality_reason
|
||||
@@ -611,10 +630,7 @@ ON DUPLICATE KEY UPDATE
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
sample_count = sample_count + VALUES(sample_count),
|
||||
first_event_time = CASE
|
||||
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time THEN VALUES(first_event_time)
|
||||
ELSE first_event_time
|
||||
END,
|
||||
first_event_time = ` + upsertSourceMergedFirstEventSQL + `,
|
||||
latest_event_time = CASE
|
||||
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_event_time)
|
||||
ELSE latest_event_time
|
||||
|
||||
@@ -344,7 +344,7 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
|
||||
"ON DUPLICATE KEY UPDATE",
|
||||
"sample_count = sample_count + VALUES(sample_count)",
|
||||
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
|
||||
"WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
||||
normalizePlatformFirstEventSQL + " < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
||||
"THEN '" + QualityInvalidDelta + "'",
|
||||
"ELSE '" + QualityOK + "'",
|
||||
"THEN 'negative_jitter_clamped'",
|
||||
@@ -392,6 +392,25 @@ func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileagePreservesTheNewerBoundaryPair(t *testing.T) {
|
||||
// A stale IP-key candidate must not change the canonical baseline's time
|
||||
// back to September while retaining October's first_total_mileage_km.
|
||||
for _, expression := range []string{normalizePlatformFirstTotalSQL, normalizePlatformFirstEventSQL} {
|
||||
if !strings.Contains(expression, "ORDER BY "+normalizePlatformFirstOrderSQL) {
|
||||
t.Fatal("baseline total and timestamp must select the same legacy row")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(normalizePlatformFirstOrderSQL, "THEN s.first_event_time ELSE NULL END DESC") {
|
||||
t.Fatal("prior-day boundaries must prefer the newest timestamp")
|
||||
}
|
||||
if !strings.Contains(normalizePlatformSourceMileageInsertSQL, "first_event_time = "+upsertSourceMergedFirstEventSQL) {
|
||||
t.Fatal("legacy upsert must use the same boundary preference as its odometer")
|
||||
}
|
||||
if strings.Contains(normalizePlatformSourceMileageInsertSQL, "MIN(s.first_event_time)") {
|
||||
t.Fatal("an old forwarding alias must not restore an older boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/gzev"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gzev-daily-report:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
preview := flag.Bool("preview", false, "print summary without sending or recording delivery")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cfg, err := mysql.ParseDSN(os.Getenv("MYSQL_DSN"))
|
||||
if err != nil {
|
||||
return errors.New("invalid MYSQL_DSN")
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.UTC
|
||||
cfg.Timeout = 10 * time.Second
|
||||
cfg.ReadTimeout = 30 * time.Second
|
||||
db, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
return errors.New("cannot initialize MySQL")
|
||||
}
|
||||
defer db.Close()
|
||||
account := env("GZEV_ACCOUNT", "guangdonglingniu")
|
||||
report, err := gzev.LoadDailyReport(ctx, db, account, env("GZEV_START_DATE", "2024-01-01"), time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title, body := report.Message()
|
||||
if *preview {
|
||||
fmt.Println(title + "\n\n" + body)
|
||||
return nil
|
||||
}
|
||||
sender := gzev.ServerChanFromEnv()
|
||||
sent, err := gzev.DeliverDailyReport(env("GZEV_DAILY_REPORT_STATE", "/var/lib/lingniu-gzev/daily-report.json"), report.Date, func() error { return sender.SendMessage(ctx, title, body) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sent {
|
||||
fmt.Printf("ServerChan accepted daily report: date=%s fetched_days=%d expected_days=%d missing_dates=%d\n", report.Date, report.Fetched, report.Expected, len(report.Missing))
|
||||
} else {
|
||||
fmt.Printf("Daily report already accepted; skipped duplicate: date=%s\n", report.Date)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func env(k, fallback string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/gzev"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gzev-mileage:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
mode := flag.String("mode", "sync", "sync (MySQL), fetch (raw daily files), probe (one complete day), status (MySQL coverage), notify-test (one ServerChan test push)")
|
||||
start := flag.String("from", env("GZEV_START_DATE", "2024-01-01"), "inclusive first day")
|
||||
end := flag.String("to", "", "inclusive last day; defaults to yesterday")
|
||||
sessionPath := flag.String("session", env("GZEV_SESSION_FILE", "/var/lib/lingniu-gzev/session.json"), "0600 JSON session file")
|
||||
out := flag.String("out", "", "fetch output directory")
|
||||
maxDays := flag.Int("max-days", 30, "maximum days fetched per run; oldest unfinished dates resume next run")
|
||||
lookback := flag.Int("lookback", 7, "re-fetch recent complete days")
|
||||
flag.Parse()
|
||||
if *mode != "sync" && *mode != "fetch" && *mode != "probe" && *mode != "status" && *mode != "notify-test" {
|
||||
return errors.New("unknown mode")
|
||||
}
|
||||
if *maxDays < 1 || *lookback < 0 {
|
||||
return errors.New("invalid day limits")
|
||||
}
|
||||
today := gzev.ShanghaiToday(time.Now())
|
||||
if *end == "" {
|
||||
*end = today.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
first, err := gzev.ParseDate(*start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
last, err := gzev.ParseDate(*end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if first.After(last) || gzev.ValidateDate(*end, time.Now()) != nil {
|
||||
return errors.New("invalid range: end must be before today in Asia/Shanghai")
|
||||
}
|
||||
var session gzev.Session
|
||||
var sessionErr error
|
||||
if *mode != "status" && *mode != "notify-test" {
|
||||
session, sessionErr = gzev.ReadSession(*sessionPath)
|
||||
if sessionErr != nil && *mode != "sync" {
|
||||
return sessionErr
|
||||
}
|
||||
}
|
||||
client := gzev.NewClient(session)
|
||||
account := env("GZEV_ACCOUNT", "guangdonglingniu")
|
||||
if len(account) > 64 {
|
||||
return errors.New("account too long")
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if *mode == "notify-test" {
|
||||
if err := gzev.ServerChanFromEnv().SendTest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("ServerChan accepted test notification")
|
||||
return nil
|
||||
}
|
||||
if *mode == "probe" || *mode == "fetch" {
|
||||
if err := client.VerifyAccount(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if *mode == "probe" {
|
||||
client.PageSize = 500
|
||||
day, err := client.FetchDay(ctx, *end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("date=%s rows=%d pages=%d\n", day.Date, len(day.Rows), len(day.Pages))
|
||||
return nil
|
||||
}
|
||||
if *mode == "fetch" {
|
||||
if *out == "" {
|
||||
return errors.New("fetch requires --out")
|
||||
}
|
||||
if err := os.MkdirAll(*out, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
count := 0
|
||||
for day := first; !day.After(last) && count < *maxDays; day = day.AddDate(0, 0, 1) {
|
||||
date := day.Format("2006-01-02")
|
||||
path := filepath.Join(*out, date+".json")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
result, err := client.FetchDay(ctx, date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(*out, ".gzev-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := tmp.Name()
|
||||
_, writeErr := tmp.Write(raw)
|
||||
syncErr := tmp.Sync()
|
||||
closeErr := tmp.Close()
|
||||
if err := errors.Join(writeErr, syncErr, closeErr); err != nil {
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
fmt.Printf("downloaded date=%s rows=%d pages=%d\n", date, len(result.Rows), len(result.Pages))
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dsn := os.Getenv("MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
return errors.New("MYSQL_DSN is required for sync")
|
||||
}
|
||||
config, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return errors.New("invalid MYSQL_DSN")
|
||||
}
|
||||
config.ParseTime = true
|
||||
config.Loc = time.UTC
|
||||
config.Timeout = 10 * time.Second
|
||||
config.ReadTimeout = 60 * time.Second
|
||||
config.WriteTimeout = 60 * time.Second
|
||||
db, err := sql.Open("mysql", config.FormatDSN())
|
||||
if err != nil {
|
||||
return errors.New("cannot initialize MySQL")
|
||||
}
|
||||
defer db.Close()
|
||||
if *mode == "status" {
|
||||
var days, records, missing int64
|
||||
var latest sql.NullString
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*),COALESCE(SUM(row_count),0),COALESCE(SUM(missing_count),0),CAST(MAX(last_success_at) AS CHAR) FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ?", account, *start, *end).Scan(&days, &records, &missing, &latest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"account": account, "from": *start, "to": *end, "expected_days": int(last.Sub(first).Hours()/24) + 1, "fetched_days": days, "rows": records, "missing_rows": missing, "last_success_utc": latest.String})
|
||||
}
|
||||
err = gzev.WithLock(ctx, db, account, func(store *gzev.Store) error {
|
||||
if sessionErr != nil {
|
||||
return errors.Join(gzev.ErrAuth, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send))
|
||||
}
|
||||
if err := client.VerifyAccount(ctx, account); err != nil {
|
||||
if errors.Is(err, gzev.ErrAuth) {
|
||||
return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := store.ResolveAuth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
count := 0
|
||||
var failures []error
|
||||
// First collect yesterday so a long historical backfill cannot starve daily updates.
|
||||
dates := []time.Time{}
|
||||
recentStart := today.AddDate(0, 0, -*lookback)
|
||||
for d := last; !d.Before(first) && !d.Before(recentStart); d = d.AddDate(0, 0, -1) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
|
||||
if d.Before(recentStart) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
for _, day := range dates {
|
||||
if count >= *maxDays {
|
||||
break
|
||||
}
|
||||
date := day.Format("2006-01-02")
|
||||
recent := !day.Before(today.AddDate(0, 0, -*lookback))
|
||||
need, err := store.NeedsDay(ctx, date, recent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
// Read again for each day: operators can atomically replace the session during backfill.
|
||||
s, err := gzev.ReadSession(*sessionPath)
|
||||
if err != nil {
|
||||
return errors.Join(gzev.ErrAuth, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send))
|
||||
}
|
||||
changed := s.Authorization != client.Session.Authorization
|
||||
client.Session = s
|
||||
if changed {
|
||||
if err := client.VerifyAccount(ctx, account); err != nil {
|
||||
if errors.Is(err, gzev.ErrAuth) {
|
||||
return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := client.FetchDay(ctx, date)
|
||||
count++
|
||||
if errors.Is(err, gzev.ErrAuth) {
|
||||
return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send))
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if failureErr := store.RecordFailure(ctx, date); failureErr != nil {
|
||||
return failureErr
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("%s: %w", date, err))
|
||||
continue
|
||||
}
|
||||
if err := store.ResolveAuth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.SaveDay(ctx, result); err != nil {
|
||||
if failureErr := store.RecordFailure(ctx, date); failureErr != nil {
|
||||
return failureErr
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("%s: %w", date, err))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("stored date=%s rows=%d pages=%d\n", date, len(result.Rows), len(result.Pages))
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
})
|
||||
return err
|
||||
}
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/gzev"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := os.Getenv("GZEV_SESSION_FILE")
|
||||
if path == "" {
|
||||
path = "/var/lib/lingniu-gzev/session.json"
|
||||
}
|
||||
account := os.Getenv("GZEV_ACCOUNT")
|
||||
if account == "" {
|
||||
account = "guangdonglingniu"
|
||||
}
|
||||
handler, err := gzev.NewUploadHandler(path, account)
|
||||
if err != nil {
|
||||
log.Fatal("cannot initialize upload handler")
|
||||
}
|
||||
handler.PublicOrigin = "https://vehicle.d.lnoneos.com"
|
||||
handler.RecoveryPath = path + ".recovery.json"
|
||||
addr := os.Getenv("GZEV_UPLOAD_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:20312"
|
||||
}
|
||||
server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 40 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
stop, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(stop)
|
||||
}()
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("upload service stopped unexpectedly")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Command oneos-local-fixture is a disposable loopback-only OneOS simulator.
|
||||
// It is NOT a OneOS implementation or an upstream deployment artifact.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const clientID = "vehicle-platform-local"
|
||||
const tenantID = "local-test"
|
||||
const password = "LocalOnly123!"
|
||||
|
||||
type user struct {
|
||||
ID, Username, Name, VIN string
|
||||
Disabled, NoAccess bool
|
||||
}
|
||||
type session struct {
|
||||
User string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type ticket struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type fixture struct {
|
||||
mu sync.Mutex
|
||||
key *rsa.PrivateKey
|
||||
serviceToken, platformOrigin string
|
||||
users map[string]*user
|
||||
sessions map[string]session
|
||||
tickets map[string]ticket
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func response(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data})
|
||||
}
|
||||
func loopbackOrigin(value string) bool {
|
||||
u, e := url.Parse(value)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
return u.Scheme == "http" && ip != nil && ip.IsLoopback() && u.User == nil && u.RawQuery == "" && u.Fragment == "" && u.Path == ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:20410", "literal loopback listener")
|
||||
origin := flag.String("platform-origin", "http://127.0.0.1:20400", "local platform origin")
|
||||
configPath := flag.String("config-file", "", "required private JSON output for local launcher")
|
||||
flag.Parse()
|
||||
host, _, e := net.SplitHostPort(*listen)
|
||||
if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() || !loopbackOrigin(*origin) || *configPath == "" {
|
||||
log.Fatal("literal loopback endpoints and config-file required")
|
||||
}
|
||||
key, e := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if e != nil {
|
||||
log.Fatal(e)
|
||||
}
|
||||
f := &fixture{key: key, serviceToken: randomToken(), platformOrigin: *origin, users: map[string]*user{"alpha": {ID: "101", Username: "alpha", Name: "本地甲组用户", VIN: "LB9A32A24R0LS1426"}, "beta": {ID: "102", Username: "beta", Name: "本地乙组用户", VIN: "LNXNEGRR7SR318212"}, "noaccess": {ID: "103", Username: "noaccess", Name: "本地未授权用户", NoAccess: true}}, sessions: map[string]session{}, tickets: map[string]ticket{}}
|
||||
public, e := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if e != nil {
|
||||
log.Fatal(e)
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string{"baseURL": "http://" + *listen, "publicOrigin": *origin, "issuer": "oneos-local-fixture", "tenantId": tenantID, "clientId": clientID, "serviceToken": f.serviceToken, "publicKey": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))})
|
||||
file, e := os.OpenFile(*configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if e != nil {
|
||||
log.Fatal("cannot create private local config")
|
||||
}
|
||||
_, e = file.Write(data)
|
||||
file.Close()
|
||||
if e != nil {
|
||||
log.Fatal(e)
|
||||
}
|
||||
server := &http.Server{Addr: *listen, Handler: f, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
|
||||
log.Printf("Local fixture only: http://%s (accounts alpha/beta/noaccess; no external clients)", *listen)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
func (f *fixture) issue(name string) (string, session) {
|
||||
s := session{User: name, ExpiresAt: time.Now().Add(20 * time.Minute)}
|
||||
token := randomToken()
|
||||
f.sessions[token] = s
|
||||
return token, s
|
||||
}
|
||||
func (f *fixture) current(r *http.Request) (string, *user, session, bool) {
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
s, ok := f.sessions[token]
|
||||
u := f.users[s.User]
|
||||
return token, u, s, ok && u != nil && !u.Disabled && s.ExpiresAt.After(time.Now()) && r.Header.Get("ClientID") == clientID
|
||||
}
|
||||
|
||||
func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if r.URL.Path == "/health" {
|
||||
response(w, 200, map[string]bool{"localOnly": true})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/auth/code" && r.Method == "GET" {
|
||||
response(w, 200, map[string]any{"captchaEnabled": false, "uuid": "", "img": ""})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/auth/login" && r.Method == "POST" {
|
||||
input, err := f.decryptLogin(r)
|
||||
if err != nil || input["clientId"] != clientID || input["tenantId"] != tenantID || input["grantType"] != "password" || input["password"] != password || f.users[input["username"]] == nil || f.users[input["username"]].Disabled {
|
||||
response(w, 401, nil)
|
||||
return
|
||||
}
|
||||
token, s := f.issue(input["username"])
|
||||
response(w, 200, map[string]any{"access_token": token, "client_id": clientID, "expire_in": int(time.Until(s.ExpiresAt).Seconds())})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/auth/inner/v1/sso/vehicle-platform/token-exchange" && r.Method == "POST" {
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+f.serviceToken)) != 1 {
|
||||
response(w, 401, nil)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil {
|
||||
response(w, 400, nil)
|
||||
return
|
||||
}
|
||||
t, ok := f.tickets[input.Code]
|
||||
delete(f.tickets, input.Code)
|
||||
s, live := f.sessions[t.Token]
|
||||
u := f.users[s.User]
|
||||
if !ok || !live || !t.ExpiresAt.After(time.Now()) || !s.ExpiresAt.After(time.Now()) || u == nil || u.Disabled {
|
||||
response(w, 401, nil)
|
||||
return
|
||||
}
|
||||
response(w, 200, map[string]any{"accessToken": t.Token, "tokenType": "Bearer", "clientId": clientID, "expiresAt": s.ExpiresAt})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/system/user/getInfo" || r.URL.Path == "/asset/vehicle-platform/scope" || r.URL.Path == "/auth/logout" {
|
||||
token, u, _, ok := f.current(r)
|
||||
if !ok {
|
||||
response(w, 401, nil)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/auth/logout":
|
||||
if r.Method != "POST" {
|
||||
response(w, 405, nil)
|
||||
return
|
||||
}
|
||||
delete(f.sessions, token)
|
||||
response(w, 200, map[string]bool{"loggedOut": true})
|
||||
case "/system/user/getInfo":
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:monitor:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"}
|
||||
if u.NoAccess {
|
||||
permissions = []string{}
|
||||
}
|
||||
response(w, 200, map[string]any{"user": map[string]any{"userId": u.ID, "tenantId": tenantID, "userName": u.Username, "nickName": u.Name, "deptId": u.ID, "deptName": "本地测试部门", "status": "0"}, "permissions": permissions, "roles": []string{"local-business-user"}})
|
||||
case "/asset/vehicle-platform/scope":
|
||||
vehicles := []map[string]any{}
|
||||
if u.VIN != "" && !u.NoAccess {
|
||||
vehicles = append(vehicles, map[string]any{"vin": u.VIN, "validFrom": "2026-01-01T00:00:00Z"})
|
||||
}
|
||||
response(w, 200, map[string]any{"userId": u.ID, "tenantId": tenantID, "permissionVersion": "fixture-v1", "expiresAt": time.Now().Add(30 * time.Second), "vehicles": vehicles})
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/__local/control" && r.Method == "POST" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+f.serviceToken {
|
||||
response(w, 401, nil)
|
||||
return
|
||||
}
|
||||
var input struct{ Username, Action string }
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || f.users[input.Username] == nil {
|
||||
response(w, 400, nil)
|
||||
return
|
||||
}
|
||||
u := f.users[input.Username]
|
||||
switch input.Action {
|
||||
case "disable":
|
||||
u.Disabled = true
|
||||
case "enable":
|
||||
u.Disabled = false
|
||||
case "revoke-vehicles":
|
||||
u.VIN = ""
|
||||
case "restore-vehicles":
|
||||
if u.Username == "alpha" {
|
||||
u.VIN = "LB9A32A24R0LS1426"
|
||||
} else if u.Username == "beta" {
|
||||
u.VIN = "LNXNEGRR7SR318212"
|
||||
}
|
||||
default:
|
||||
response(w, 400, nil)
|
||||
return
|
||||
}
|
||||
response(w, 200, map[string]bool{"localOnly": true})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/portal/login" && r.Method == "POST" {
|
||||
if r.ParseForm() != nil || r.FormValue("password") != password || f.users[r.FormValue("username")] == nil {
|
||||
http.Error(w, "本地测试账号或密码错误", 401)
|
||||
return
|
||||
}
|
||||
token, _ := f.issue(r.FormValue("username"))
|
||||
http.SetCookie(w, &http.Cookie{Name: "fixture_oneos", Value: token, HttpOnly: true, SameSite: http.SameSiteLaxMode, Path: "/"})
|
||||
http.Redirect(w, r, "/", 303)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/portal/ticket" && r.Method == "POST" {
|
||||
cookie, e := r.Cookie("fixture_oneos")
|
||||
if e != nil {
|
||||
http.Error(w, "请先登录本地模拟 OneOS", 401)
|
||||
return
|
||||
}
|
||||
s, ok := f.sessions[cookie.Value]
|
||||
if !ok || !s.ExpiresAt.After(time.Now()) {
|
||||
http.Error(w, "本地会话已失效", 401)
|
||||
return
|
||||
}
|
||||
code := randomToken()
|
||||
f.tickets[code] = ticket{Token: cookie.Value, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
http.Redirect(w, r, f.platformOrigin+"/api/v2/auth/oneos/callback?code="+url.QueryEscape(code), 303)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" && r.Method == "GET" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
name := ""
|
||||
if c, e := r.Cookie("fixture_oneos"); e == nil {
|
||||
if s, ok := f.sessions[c.Value]; ok && s.ExpiresAt.After(time.Now()) {
|
||||
name = s.User
|
||||
}
|
||||
}
|
||||
_ = portal.Execute(w, map[string]string{"Name": name})
|
||||
return
|
||||
}
|
||||
response(w, 404, nil)
|
||||
}
|
||||
|
||||
var portal = template.Must(template.New("portal").Parse(`<!doctype html><html lang="zh"><meta charset="utf-8"><title>本地 OneOS 模拟器</title><style>body{font:16px system-ui;max-width:640px;margin:80px auto;background:#f4f6fa}main{background:white;padding:36px;border-radius:20px}label{display:block;margin:18px 0}input,button{padding:12px;font:inherit}button{cursor:pointer}small{color:#596579}</style><main><h1>本地 OneOS 模拟器</h1><p>仅回环地址 · 虚构账号 · 不连接线上</p>{{if .Name}}<p>当前 OneOS 用户:<strong>{{.Name}}</strong></p><form method="post" action="/portal/ticket"><button>使用一次性 Ticket 进入中台</button></form>{{end}}<form method="post" action="/portal/login"><label>账号 <input name="username" value="alpha" required></label><label>密码 <input name="password" type="password" value="LocalOnly123!" required></label><button>登录本地 OneOS</button></form><p><small>测试账号 alpha / beta / noaccess,密码 LocalOnly123!。前两个账号分别授权一辆不同车辆;第三个没有中台入口权限。</small></p></main></html>`))
|
||||
|
||||
func (f *fixture) decryptLogin(r *http.Request) (map[string]string, error) {
|
||||
wrapper, e := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
encoded, e := rsa.DecryptPKCS1v15(rand.Reader, f.key, wrapper)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
key, e := base64.StdEncoding.DecodeString(string(encoded))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
raw, e := io.ReadAll(io.LimitReader(r.Body, 8193))
|
||||
if e != nil || len(raw) > 8192 {
|
||||
return nil, fmt.Errorf("bad body")
|
||||
}
|
||||
cipher, e := base64.StdEncoding.DecodeString(string(raw))
|
||||
if e != nil || len(cipher) == 0 || len(cipher)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("bad cipher")
|
||||
}
|
||||
block, e := aes.NewCipher(key)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
plain := make([]byte, len(cipher))
|
||||
for i := 0; i < len(cipher); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], cipher[i:i+aes.BlockSize])
|
||||
}
|
||||
pad := int(plain[len(plain)-1])
|
||||
if pad < 1 || pad > aes.BlockSize || pad > len(plain) {
|
||||
return nil, fmt.Errorf("bad padding")
|
||||
}
|
||||
for _, b := range plain[len(plain)-pad:] {
|
||||
if int(b) != pad {
|
||||
return nil, fmt.Errorf("bad padding")
|
||||
}
|
||||
}
|
||||
var input map[string]string
|
||||
e = json.Unmarshal(plain[:len(plain)-pad], &input)
|
||||
return input, e
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if err := app.ValidateLocalIsolation(cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("vehicle data platform listening on %s", cfg.HTTPAddr)
|
||||
if err := http.ListenAndServe(cfg.HTTPAddr, app.NewServer(cfg)); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
@@ -27,12 +29,14 @@ type tokenPrincipal struct {
|
||||
}
|
||||
|
||||
type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
adapters []IdentityAdapter
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
profile *oneOSProfileAuth
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
localBackend bool
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
|
||||
@@ -62,14 +66,32 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(cfg)
|
||||
legacyConfig := cfg
|
||||
if cfg.OneOSAuthEnabled {
|
||||
legacyConfig.OneOSSSOEnabled = false
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(legacyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oneOS != nil && local == nil {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_ENABLED requires MYSQL_DSN and the platform identity schema")
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS, localBackend: cfg.OneOSLocalBackend}
|
||||
if cfg.OneOSAuthEnabled {
|
||||
localMock := (cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) && strings.EqualFold(cfg.DataMode, "mock")
|
||||
if mode != "enforce" || (local == nil && !localMock) {
|
||||
return nil, fmt.Errorf("OneOS auth requires AUTH_MODE=enforce and identity DB or explicit isolated local mock")
|
||||
}
|
||||
if !validOneOSOrigin(cfg.OneOSPublicOrigin, cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) {
|
||||
return nil, fmt.Errorf("ONEOS_PUBLIC_ORIGIN must be a trusted HTTPS origin or explicit local loopback origin")
|
||||
}
|
||||
client, e := newOneOSProfileClient(cfg)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
authenticator.profile = &oneOSProfileAuth{localBackend: cfg.OneOSLocalBackend, vehicleQueryScope: cfg.OneOSVehicleQueryScope, devIntegration: cfg.OneOSDevIntegration, devDataProxy: cfg.OneOSDevDataProxyURL != "", client: client, store: local, origin: strings.TrimRight(cfg.OneOSPublicOrigin, "/"), issuer: cfg.OneOSProfileIssuer, tenant: cfg.OneOSProfileAllowedTenant, passwordEnabled: cfg.OneOSPasswordEnabled, scopeEnabled: cfg.OneOSVehicleScopePath != "" || cfg.OneOSVehicleQueryScope, localOnly: localMock, ssoEnabled: localMock && cfg.OneOSLoginTicketExchangePath != ""}
|
||||
}
|
||||
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
|
||||
authenticator.demo = newDemoAuthDirectory()
|
||||
}
|
||||
@@ -101,7 +123,7 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)}
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal})
|
||||
}
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && authenticator.profile == nil && len(authenticator.adapters) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
|
||||
}
|
||||
return authenticator, nil
|
||||
@@ -109,6 +131,18 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
|
||||
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/providers" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"oneos": map[string]bool{"enabled": a.profile != nil, "devIntegration": a.profile != nil && a.profile.devIntegration, "realDataEnabled": a.profile != nil && (a.profile.devDataProxy || a.profile.localBackend), "passwordEnabled": a.profile != nil && a.profile.passwordEnabled, "authorizationReady": a.profile != nil && a.profile.scopeEnabled, "ssoEnabled": a.profile != nil && a.profile.ssoEnabled}, "local": map[string]bool{"enabled": a.local != nil}})
|
||||
return
|
||||
}
|
||||
if a.profile != nil && a.profile.handle(w, r) {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/oneos/exchange" {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r))
|
||||
@@ -133,13 +167,79 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
a.local.login(w, r)
|
||||
return
|
||||
}
|
||||
// This route resolves the caller's own provider; request parameters never select it.
|
||||
if r.URL.Path == "/api/v2/auth/vehicle-scope" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
current, err := a.profile.client.CurrentUser(r.Context(), token, clientID)
|
||||
if err != nil || !a.profile.validProfile(current.Profile) {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
p, _, err := a.profile.resolveVehicleQueryScope(r.Context(), token, clientID, current)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "VEHICLE_SCOPE_UNAVAILABLE", "车辆权限暂时加载失败,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
p, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
limited := principal.AuthorizationStatus == "pending" || (a.profile.devIntegration && principal.AuthorizationStatus == "asset-catalog")
|
||||
identityRoute := (r.Method == http.MethodGet && (r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me")) || (r.Method == http.MethodPost && r.URL.Path == "/api/v2/auth/logout")
|
||||
catalogRoute := principal.AuthorizationStatus != "pending" && r.Method == http.MethodGet && r.URL.Path == "/api/v2/auth/oneos/vehicles" && (principal.AuthorizationStatus == "asset-catalog" || (a.profile.vehicleQueryScope || a.profile.devDataProxy && principal.Role == "admin"))
|
||||
action := oneOSReadAction(r)
|
||||
admin := oneOSHasAdminPermission(principal.PermissionKeys) && principal.Role == "admin"
|
||||
if limited && admin && !identityRoute && !catalogRoute {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_DATA_UNAVAILABLE", "管理员权限已开通,本地开发环境尚未连接中台业务数据源", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if (limited && !identityRoute && !catalogRoute) || (!limited && !admin && !identityRoute && !catalogRoute && (action == "" || !oneOSPermissions(principal.PermissionKeys)[action])) {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_PERMISSION_DENIED")
|
||||
return
|
||||
}
|
||||
if catalogRoute {
|
||||
page, err := a.profile.client.VehicleAssetPage(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID"), r.URL.Query())
|
||||
if err != nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if errors.Is(err, errOneOSAssetQuery) {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "ONEOS_CATALOG_QUERY_INVALID", "车辆目录查询参数无效,请检查筛选条件及分页范围", "", requestTraceID(r))
|
||||
} else {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 车辆目录暂不可用,请稍后重试", "", requestTraceID(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"records": page.Records, "total": page.Total, "current": page.Current, "size": page.Size, "telemetryAvailable": false, "historyAvailable": false, "source": "oneos-asset-catalog"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" && r.URL.Path != "/api/v2/auth/oneos/me" {
|
||||
menu := requiredMenu(r)
|
||||
allowed := principal.CanMenu(menu)
|
||||
if menu == "shared" {
|
||||
@@ -159,8 +259,45 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.localBackend {
|
||||
principal.DataSource = "production-local"
|
||||
}
|
||||
ctx := platform.WithPrincipal(r.Context(), principal)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/customer-mileage") || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance") {
|
||||
snapshot := platform.CustomerAssignmentSnapshot{Tenant: principal.TenantRef}
|
||||
if principal.AuthProvider != "oneos" && a.profile != nil {
|
||||
snapshot.Tenant = a.profile.tenant
|
||||
}
|
||||
if principal.AuthProvider == "oneos" && (r.URL.Path == "/api/v2/customer-mileage" || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance")) && r.Method == http.MethodGet && a.profile != nil && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
rows, err := a.profile.vehicleQuerySnapshot(ctx, token, clientID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 客户车辆归属暂不可用,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
snapshot.Live, snapshot.ObservedAt = true, time.Now()
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.profile.scopeMu.Lock()
|
||||
if entry := a.profile.queryScopes[key]; entry != nil && !entry.expires.IsZero() {
|
||||
snapshot.ObservedAt = entry.expires.Add(-oneOSQueryScopeTTL)
|
||||
}
|
||||
a.profile.scopeMu.Unlock()
|
||||
for _, row := range rows {
|
||||
snapshot.Rows = append(snapshot.Rows, platform.CustomerVehicleAssignment{VIN: row.VIN, Plate: row.PlateNumber, Customer: row.CustomerName, Department: row.BusinessDepartment, ResponsiblePerson: row.BusinessResponsiblePerson})
|
||||
}
|
||||
}
|
||||
ctx = platform.WithCustomerAssignments(ctx, snapshot)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if r.URL.Path == "/api/v2/auth/oneos/me" {
|
||||
if a.profile == nil || principal.AuthProvider != "oneos" || r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_REQUIRED")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"username": principal.Username, "displayName": principal.Name, "tenantId": principal.TenantRef, "menuKeys": principal.MenuKeys, "authorizationStatus": principal.AuthorizationStatus})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/session" {
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
@@ -174,7 +311,7 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos" || principal.AuthProvider == "oneos-profile") {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
@@ -206,8 +343,14 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
|
||||
if token == "" {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
return a.profile.authenticate(r, token)
|
||||
}
|
||||
if a.local != nil {
|
||||
if principal, ok := a.local.authenticate(r.Context(), token); ok {
|
||||
if principal.AuthProvider == "oneos-profile" || (a.profile != nil && principal.AuthProvider == "oneos") {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
@@ -245,13 +388,17 @@ func bearerToken(r *http.Request) string {
|
||||
func requiredMenu(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/operation-compliance"):
|
||||
return "operation-compliance"
|
||||
case strings.HasPrefix(path, "/api/v2/customer-mileage"):
|
||||
return "customer-mileage"
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/v2/statistics/vehicles", path == "/api/v2/statistics/vehicle-options", path == "/api/v2/statistics/vehicle-resolve":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
|
||||
return "shared"
|
||||
@@ -263,6 +410,9 @@ func requiredMenu(r *http.Request) string {
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if r.URL.Path == "/api/v2/customer-mileage/rules" && r.Method != http.MethodGet {
|
||||
return "admin"
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
|
||||
return "admin"
|
||||
}
|
||||
@@ -293,7 +443,7 @@ func requiredRole(r *http.Request) string {
|
||||
switch path {
|
||||
case "/api/v2/auth/logout":
|
||||
return "viewer"
|
||||
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
case "/api/v2/statistics/vehicle-resolve", "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
return "viewer"
|
||||
case "/api/v2/alerts/notifications/read":
|
||||
return "operator"
|
||||
|
||||
@@ -35,14 +35,14 @@ const (
|
||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`)
|
||||
|
||||
var customerMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true,
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true, "customer-mileage": true, "operation-compliance": true,
|
||||
}
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "customer-mileage", "operation-compliance", "alerts", "access", "operations", "users"}
|
||||
|
||||
var adminMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true, "customer-mileage": true, "operation-compliance": true,
|
||||
}
|
||||
|
||||
type authUser struct {
|
||||
@@ -294,11 +294,9 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), credential.authUser)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
// Issue identity first; vehicle grants are loaded by the authenticated scope endpoint.
|
||||
principal := platform.Principal{SubjectID: strconv.FormatUint(credential.ID, 10), Name: credential.DisplayName, Username: credential.Username, AuthProvider: "local", Role: credential.UserType, UserType: credential.UserType, AuthorizationStatus: "loading", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
|
||||
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
@@ -432,7 +430,9 @@ func (s *authStore) issueSession(r *http.Request, userID uint64, principal platf
|
||||
return loginResponse{}, err
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
if principal.AuthorizationStatus != "loading" {
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
}
|
||||
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomerMileageOneOSPermissionMapping(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/v2/customer-mileage", nil)
|
||||
if requiredMenu(request) != "customer-mileage" || oneOSReadAction(request) != "vehiclePlatform:customerMileage:view" {
|
||||
t.Fatal("OneOS legal permission key not mapped to route")
|
||||
}
|
||||
if !oneOSPermissions([]string{"*:*:*"})["vehiclePlatform:customerMileage:view"] {
|
||||
t.Fatal("administrator lost customer mileage menu")
|
||||
}
|
||||
if oneOSPermissions([]string{"vehiclePlatform:statistics:read"})["vehiclePlatform:customerMileage:view"] {
|
||||
t.Fatal("ordinary mileage implicitly authorized hydrogen")
|
||||
}
|
||||
request = httptest.NewRequest("PUT", "/api/v2/customer-mileage/rules", nil)
|
||||
if requiredRole(request) != "admin" || oneOSReadAction(request) != "" {
|
||||
t.Fatal("read permission can mutate rules")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOneOSAdminPermissionRequiresAuthoritativeGrant(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
permissions []string
|
||||
admin bool
|
||||
}{
|
||||
{"super administrator", []string{"*:*:*"}, true},
|
||||
{"platform administrator", []string{"vehicle:platform:access", oneOSAdminPermission}, true},
|
||||
{"missing entry", []string{oneOSAdminPermission}, false},
|
||||
{"menu only", []string{"vehicle:platform:access", "vehiclePlatform:operations:view", "vehiclePlatform:users:view"}, false},
|
||||
{"no permissions", nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := oneOSHasAdminPermission(tc.permissions); got != tc.admin {
|
||||
t.Fatalf("admin=%v want %v", got, tc.admin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSAdminFullAccessAndRevocation(t *testing.T) {
|
||||
var mode atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer admin-test-token" || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("upstream credentials changed")
|
||||
}
|
||||
if r.URL.Path != "/system/user/getInfo" {
|
||||
t.Errorf("admin should not require a separate scope endpoint: %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
permissions := []string{"vehicle:platform:access", oneOSAdminPermission}
|
||||
status := "0"
|
||||
switch mode.Load() {
|
||||
case 1:
|
||||
permissions = []string{"*:*:*"}
|
||||
case 2:
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
case 3:
|
||||
status = "1"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": status}, "permissions": permissions, "roles": []string{"superadmin"}}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := platform.PrincipalFromContext(r.Context())
|
||||
if !ok || p.Role != "admin" || p.UserType != "admin" || !p.CanVIN("ANY_VIN") {
|
||||
t.Error("admin principal missing")
|
||||
}
|
||||
for _, menu := range adminMenus {
|
||||
if !p.CanMenu(menu) {
|
||||
t.Errorf("missing menu %s", menu)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
request := func(method, path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("Authorization", "Bearer admin-test-token")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, modeValue := range []int32{0, 1} {
|
||||
mode.Store(modeValue)
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{"GET", "/api/v2/operations/tasks"}, {"POST", "/api/v2/operations/tasks"},
|
||||
{"GET", "/api/v2/admin/users"}, {"POST", "/api/v2/admin/users"},
|
||||
{"PUT", "/api/v2/access/thresholds"}, {"POST", "/api/v2/alerts/rules"},
|
||||
{"POST", "/api/history/raw-frames/query"}, {"POST", "/api/v2/exports"},
|
||||
} {
|
||||
if w := request(route.method, route.path); w.Code != 204 {
|
||||
t.Fatalf("%s %s status=%d body=%s", route.method, route.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dev administrator still cannot see mock business records as real data.
|
||||
auth.devIntegration = true
|
||||
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 503 || !strings.Contains(w.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
|
||||
t.Fatalf("dev storage leaked: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Revoking the full grant takes effect on the next request, even though the
|
||||
// username and upstream role display name still say admin/superadmin.
|
||||
mode.Store(2)
|
||||
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 403 {
|
||||
t.Fatalf("revoked admin retained operations: %d", w.Code)
|
||||
}
|
||||
p, _, err := auth.resolve(context.Background(), "admin-test-token", "web")
|
||||
if err != nil || p.Role != "customer" || p.CanMenu("users") || p.CanVIN("ANY_VIN") {
|
||||
t.Fatalf("username elevated revoked user: %+v %v", p, err)
|
||||
}
|
||||
mode.Store(3)
|
||||
if w := request("GET", "/api/v2/session"); w.Code != 401 {
|
||||
t.Fatalf("disabled admin accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// oneOSAssetVehicle contains only asset catalog fields from the OneOS vehicle
|
||||
// page. Authorization only consumes fully enumerated caller-scoped snapshots
|
||||
// when ONEOS_VEHICLE_QUERY_SCOPE is explicitly enabled.
|
||||
type oneOSAssetVehicle struct {
|
||||
ID string `json:"id"`
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
Brand string `json:"brand"`
|
||||
BrandName string `json:"brandName"`
|
||||
Model string `json:"model"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProvinceName string `json:"provinceName"`
|
||||
CityName string `json:"cityName"`
|
||||
CustomerName string `json:"customerName"`
|
||||
ProjectName string `json:"projectName"`
|
||||
BusinessDepartment string `json:"businessDepartment"`
|
||||
BusinessResponsiblePerson string `json:"businessResponsiblePerson"`
|
||||
OperationStatus string `json:"operationStatus"`
|
||||
VehicleSource string `json:"vehicleSource"`
|
||||
}
|
||||
|
||||
func (v *oneOSAssetVehicle) UnmarshalJSON(raw []byte) error {
|
||||
type fields oneOSAssetVehicle
|
||||
var payload struct {
|
||||
fields
|
||||
ID json.RawMessage `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = oneOSAssetVehicle(payload.fields)
|
||||
id, err := oneOSIDString(payload.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
type oneOSAssetPage struct {
|
||||
Records []oneOSAssetVehicle `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int64 `json:"current"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
var errOneOSAssetQuery = errors.New("invalid OneOS asset query")
|
||||
|
||||
var oneOSAssetPageFilters = map[string]bool{
|
||||
"assetCategory": true, "vin": true, "plateNumber": true, "plateNumberList": true,
|
||||
"province": true, "city": true, "provinces": true, "vehicleType": true,
|
||||
"brand": true, "model": true, "customerName": true, "businessDept": true,
|
||||
"registeredOwnership": true, "contractCode": true, "operationOrgId": true,
|
||||
"vehicleSource": true, "rentalCompany": true, "operationStatus": true,
|
||||
"vehicleStatus": true, "onlineStatus": true, "licenseStatus": true,
|
||||
"packingLotId": true, "provinceName": true, "cityName": true,
|
||||
"commercialInsuranceStatus": true, "compulsoryInsuranceStatus": true,
|
||||
"projectName": true, "insuranceStatus": true,
|
||||
}
|
||||
|
||||
const oneOSVehicleAssetCategory = "allOperating"
|
||||
|
||||
// VehicleAssetPage faithfully reads one bounded page of the OneOS vehicle
|
||||
// catalog. It neither scans all pages nor turns records into permission grants.
|
||||
func (c *oneOSProfileClient) VehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values) (oneOSAssetPage, error) {
|
||||
return c.vehicleAssetPage(ctx, token, clientID, filters, 50)
|
||||
}
|
||||
|
||||
// Larger pages are reserved for the server-side permission snapshot.
|
||||
func (c *oneOSProfileClient) vehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values, maxPageSize int64) (oneOSAssetPage, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
query := url.Values{}
|
||||
pageNum, pageSize := int64(1), int64(10)
|
||||
if filters != nil {
|
||||
for key, values := range filters {
|
||||
if key == "pageNum" || key == "pageSize" {
|
||||
if len(values) != 1 || strings.TrimSpace(values[0]) == "" {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !oneOSAssetPageFilters[key] || len(values) == 0 || len(values) > 20 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" || len(value) > 128 || strings.ContainsAny(value, "\r\n") {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
if raw := filters.Get("pageNum"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 1 || parsed > 1000000 || len(filters["pageNum"]) != 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
pageNum = parsed
|
||||
}
|
||||
if raw := filters.Get("pageSize"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 1 || parsed > maxPageSize || len(filters["pageSize"]) != 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
pageSize = parsed
|
||||
}
|
||||
}
|
||||
if len(query["assetCategory"]) > 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
// Fixed business boundary; caller filters cannot select a different category.
|
||||
query.Set("assetCategory", oneOSVehicleAssetCategory)
|
||||
query.Set("pageNum", strconv.FormatInt(pageNum, 10))
|
||||
query.Set("pageSize", strconv.FormatInt(pageSize, 10))
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/asset/vehicle-info/page?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("ClientID", clientID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return oneOSAssetPage{}, errors.New("OneOS asset page unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return oneOSAssetPage{}, fmt.Errorf("OneOS asset page rejected: HTTP %d", response.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(raw) > 1<<20 {
|
||||
return oneOSAssetPage{}, errors.New("OneOS asset page too large")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[oneOSAssetPage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset page response")
|
||||
}
|
||||
page := envelope.Data
|
||||
if page.Total < 0 || page.Current != pageNum || page.Size != pageSize || int64(len(page.Records)) > pageSize {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset page metadata")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSVehicleAssetPageForwardsBoundedOriginalQuery(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/asset/vehicle-info/page" {
|
||||
t.Error("wrong asset endpoint")
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
|
||||
t.Error("missing OneOS credential")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("pageNum") != "2" || q.Get("pageSize") != "10" || q.Get("assetCategory") != "allOperating" || q.Get("vin") != "VIN123" || q.Get("businessDept") != "East" || len(q["provinces"]) != 3 || q["provinces"][0] != "410000" || q["provinces"][1] != "420000" || q["provinces"][2] != "430000" {
|
||||
t.Errorf("wrong query %v", q)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"records":[{"id":9123456789012345678,"vin":"VIN123","plateNumber":"A123","brand":"Brand","model":"Model","businessDepartment":"East"}],"total":11,"current":2,"size":10}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "client-a"}
|
||||
page, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"assetCategory": {"stock"}, "pageNum": {"2"}, "pageSize": {"10"}, "vin": {"VIN123"}, "businessDept": {"East"}, "provinces": {"410000", "420000", "430000"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 11 || len(page.Records) != 1 || page.Records[0].PlateNumber != "A123" || page.Records[0].ID != "9123456789012345678" {
|
||||
t.Fatalf("wrong asset page: %+v", page)
|
||||
}
|
||||
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"pageSize": {"9999"}}); err == nil {
|
||||
t.Fatal("unbounded page accepted")
|
||||
}
|
||||
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"arbitrary": {"1"}}); err == nil {
|
||||
t.Fatal("arbitrary filter accepted")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("invalid filters reached OneOS: %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
// OneOS permissions are revalidated for each request. Administrator authority
|
||||
// comes from upstream permissions, never from a username or role display name.
|
||||
import (
|
||||
"errors"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errOneOSAuthority = errors.New("OneOS authorization unavailable")
|
||||
|
||||
const oneOSAdminPermission = "vehiclePlatform:admin:all"
|
||||
|
||||
func oneOSHasAdminPermission(values []string) bool {
|
||||
var access, admin bool
|
||||
for _, value := range values {
|
||||
if value == "*:*:*" {
|
||||
return true
|
||||
}
|
||||
access = access || value == "vehicle:platform:access"
|
||||
admin = admin || value == oneOSAdminPermission
|
||||
}
|
||||
return access && admin
|
||||
}
|
||||
|
||||
func oneOSAdminPrincipal(u authUser, p oneOSProfile, permissions []string) platform.Principal {
|
||||
return platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "admin", UserType: "admin", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: append([]string(nil), adminMenus...), AuthorizationStatus: "ready"}
|
||||
}
|
||||
|
||||
func oneOSPermissions(values []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, v := range values {
|
||||
out[v] = true
|
||||
}
|
||||
if oneOSHasAdminPermission(values) {
|
||||
out[oneOSAdminPermission] = true
|
||||
for _, menu := range adminMenus {
|
||||
out[oneOSMenuPermission(menu)] = true
|
||||
}
|
||||
for _, key := range []string{"vehicle:platform:access", "vehiclePlatform:monitor:view", "vehiclePlatform:vehicles:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"} {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func oneOSReadAction(r *http.Request) string {
|
||||
if r.URL.Path == "/api/v2/alerts/events" {
|
||||
return ""
|
||||
}
|
||||
if r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me" {
|
||||
return "vehicle:platform:access"
|
||||
}
|
||||
if requiredRole(r) != "viewer" {
|
||||
return ""
|
||||
}
|
||||
switch requiredMenu(r) {
|
||||
case "operation-compliance":
|
||||
return "vehiclePlatform:operationCompliance:view"
|
||||
case "customer-mileage":
|
||||
return "vehiclePlatform:customerMileage:view"
|
||||
case "monitor", "vehicles", "shared":
|
||||
return "vehiclePlatform:vehicle:read"
|
||||
case "tracks":
|
||||
return "vehiclePlatform:track:read"
|
||||
case "statistics":
|
||||
return "vehiclePlatform:statistics:read"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func oneOSScopedPrincipal(u authUser, p oneOSProfile, permissions []string, scope oneOSVehicleScope, now time.Time) (platform.Principal, error) {
|
||||
if oneOSHasAdminPermission(permissions) {
|
||||
return oneOSAdminPrincipal(u, p, permissions), nil
|
||||
}
|
||||
grants := oneOSPermissions(permissions)
|
||||
if !grants["vehicle:platform:access"] || scope.TenantID != p.TenantID || scope.UserID != p.UserID || scope.PermissionVersion == "" || !scope.ExpiresAt.After(now) || scope.ExpiresAt.Sub(now) > 60*time.Second {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
if len(scope.Vehicles) > 100000 {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
for _, v := range scope.Vehicles {
|
||||
if len(v.VIN) == 0 || len(v.VIN) > 64 || v.VIN != strings.ToUpper(strings.TrimSpace(v.VIN)) || seen[v.VIN] || v.ValidFrom.IsZero() || (v.ValidTo != nil && !v.ValidTo.After(v.ValidFrom)) {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
seen[v.VIN] = true
|
||||
}
|
||||
principal := platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "customer", UserType: "customer", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: []string{}}
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
principal.MenuKeys = append(principal.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
for _, v := range scope.Vehicles {
|
||||
if !v.ValidFrom.After(now) && (v.ValidTo == nil || v.ValidTo.After(now)) {
|
||||
principal.VehicleVINs = append(principal.VehicleVINs, v.VIN)
|
||||
principal.VehicleGrants = append(principal.VehicleGrants, platform.VehicleGrant{VIN: v.VIN, ValidFrom: v.ValidFrom, ValidTo: v.ValidTo})
|
||||
}
|
||||
}
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func oneOSMenuPermission(menu string) string {
|
||||
if menu == "operation-compliance" {
|
||||
menu = "operationCompliance"
|
||||
}
|
||||
if menu == "customer-mileage" {
|
||||
menu = "customerMileage"
|
||||
}
|
||||
return "vehiclePlatform:" + menu + ":view"
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
// This development bridge reads an existing platform through an SSH tunnel.
|
||||
// It never forwards the browser's OneOS credentials or permits business writes.
|
||||
type oneOSDevDataProxy struct {
|
||||
base string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func validateOneOSDevDataProxy(cfg config.Config) error {
|
||||
if cfg.OneOSDevDataProxyURL == "" && cfg.OneOSDevDataProxyToken == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(cfg.OneOSDevDataProxyURL)
|
||||
if err != nil || u.Host == "" || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a literal loopback HTTP origin")
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" || u.Host == cfg.HTTPAddr {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a separate loopback tunnel port")
|
||||
}
|
||||
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
|
||||
return fmt.Errorf("OneOS dev data proxy requires authenticated isolated dev mode")
|
||||
}
|
||||
if len(cfg.OneOSDevDataProxyToken) < 16 || strings.ContainsAny(cfg.OneOSDevDataProxyToken, " \t\r\n") {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a server-side upstream credential")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOneOSDevDataProxy(cfg config.Config) *oneOSDevDataProxy {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
return &oneOSDevDataProxy{base: cfg.OneOSDevDataProxyURL, token: cfg.OneOSDevDataProxyToken, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
|
||||
var oneOSDevVehicleRead = regexp.MustCompile(`^/api/v2/vehicles/[A-Za-z0-9_-]+/(profile|telemetry/latest|source-evidence|hydrogen-evidence)$`)
|
||||
var oneOSDevVehicleDiagnostic = regexp.MustCompile(`^/api/v2/operations/vehicles/[A-Za-z0-9_-]+/sources$`)
|
||||
|
||||
func oneOSDevReadAllowed(r *http.Request) bool {
|
||||
p := r.URL.Path
|
||||
if path.Clean(p) != p || strings.Contains(r.URL.EscapedPath(), "%") {
|
||||
return false
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if oneOSDevVehicleRead.MatchString(p) || oneOSDevVehicleDiagnostic.MatchString(p) {
|
||||
return true
|
||||
}
|
||||
switch p {
|
||||
case "/api/dashboard/summary", "/api/vehicles", "/api/vehicles/resolve", "/api/vehicles/coverage", "/api/vehicles/coverage/summary", "/api/vehicles/business-filters", "/api/vehicles/detail",
|
||||
"/api/vehicle-service", "/api/vehicle-service/summary", "/api/vehicle-service/overview", "/api/realtime/vehicles", "/api/realtime/locations",
|
||||
"/api/history/locations", "/api/history/raw-frames", "/api/mileage/summary", "/api/mileage/daily", "/api/v2/statistics/mileage",
|
||||
"/api/statistics/online-summary", "/api/statistics/online-vehicles", "/api/quality/summary", "/api/quality/issues", "/api/quality/notification-plan",
|
||||
"/api/alert-events/summary", "/api/alert-events", "/api/alert-events/notification-plan", "/api/ops/health", "/api/ops/source-readiness",
|
||||
"/api/v2/monitor/summary", "/api/v2/monitor/map", "/api/v2/monitor/workspace", "/api/v2/tracks", "/api/v2/metrics",
|
||||
"/api/v2/history/metrics", "/api/v2/history/query", "/api/v2/history/series", "/api/v2/history/preferences",
|
||||
"/api/v2/access/thresholds", "/api/v2/alerts/rules", "/api/v2/alerts/rules/library", "/api/v2/alerts/notification-config",
|
||||
"/api/v2/reconciliation/summary", "/api/v2/reconciliation/assignees", "/api/map/reverse-geocode":
|
||||
return true
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
switch p {
|
||||
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage",
|
||||
"/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/access/unresolved-identities",
|
||||
"/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/reconciliation/issues":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := platform.PrincipalFromContext(r.Context())
|
||||
if !ok || principal.AuthProvider != "oneos" || principal.Role != "admin" || !oneOSHasAdminPermission(principal.PermissionKeys) {
|
||||
oneOSAuthError(w, r, http.StatusForbidden, "ONEOS_DEV_ADMIN_REQUIRED")
|
||||
return
|
||||
}
|
||||
if !oneOSDevReadAllowed(r) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "DEV_READ_ONLY", "当前为真实数据只读联调,此操作未开放;写操作请在测试数据源验收", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
p.forward(w, r, true)
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) forward(w http.ResponseWriter, r *http.Request, authenticated bool) {
|
||||
body := http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, p.base+r.URL.RequestURI(), body)
|
||||
if err != nil {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
if authenticated {
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
for _, key := range []string{"Content-Type", "Accept"} {
|
||||
if value := r.Header.Get(key); value != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
response, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 && response.StatusCode < 400 || response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode >= 500 {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1))
|
||||
if err != nil || len(payload) > 32<<20 {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", response.Header.Get("Content-Type"))
|
||||
w.Header().Set("X-Development-Data-Source", "ecs-readonly")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) unavailable(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "DEV_DATA_UPSTREAM_UNAVAILABLE", "真实数据连接暂不可用,请检查本地 SSH 隧道和上游中台服务", "", requestTraceID(r))
|
||||
}
|
||||
|
||||
// AMap SDK requests cannot carry the browser's Authorization header. Only the
|
||||
// existing public map proxy's fixed, read-only paths are available here.
|
||||
func (p *oneOSDevDataProxy) withMapProxy(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/_AMapService/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
suffix := strings.TrimPrefix(r.URL.Path, "/_AMapService")
|
||||
if r.Method != http.MethodGet || path.Clean(r.URL.Path) != r.URL.Path || strings.Contains(r.URL.EscapedPath(), "%") || !(strings.HasPrefix(suffix, "/v3/") || strings.HasPrefix(suffix, "/v4/map/styles")) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p.forward(w, r, false)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOneOSDevDataProxyReadOnlyAndCredentialBoundary(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-service-token" {
|
||||
t.Error("wrong service credential")
|
||||
}
|
||||
for _, key := range []string{"Cookie", "X-OneOS-Client-ID", "Origin", "X-Forwarded-Host"} {
|
||||
if r.Header.Get(key) != "" {
|
||||
t.Errorf("forwarded untrusted header %s", key)
|
||||
}
|
||||
}
|
||||
if r.URL.Path == "/api/v2/tracks" && r.URL.Query().Get("vin") != "VIN123" {
|
||||
t.Error("query lost")
|
||||
}
|
||||
w.Header().Set("Set-Cookie", "upstream=secret")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"data":{"real":true}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "upstream-service-token"})
|
||||
admin := platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}
|
||||
request := func(method, path string, p platform.Principal) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(`{"dateFrom":"2026-09-29","dateTo":"2026-09-29"}`))
|
||||
r.Header.Set("Authorization", "Bearer browser-oneos-token")
|
||||
r.Header.Set("Cookie", "private=secret")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
r.Header.Set("Origin", "http://127.0.0.1:20400")
|
||||
r.Header.Set("X-Forwarded-Host", "evil.invalid")
|
||||
r = r.WithContext(platform.WithPrincipal(r.Context(), p))
|
||||
w := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, route := range []struct{ method, path string }{{"GET", "/api/v2/monitor/workspace"}, {"GET", "/api/v2/tracks?vin=VIN123"}, {"POST", "/api/v2/statistics/mileage"}} {
|
||||
w := request(route.method, route.path, admin)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"real":true`) || w.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatalf("read failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
count := calls.Load()
|
||||
for _, route := range []struct{ method, path string }{{"POST", "/api/v2/alerts/rules"}, {"PUT", "/api/v2/access/thresholds"}, {"DELETE", "/api/v2/vehicles/VIN123"}, {"POST", "/api/v2/exports"}, {"POST", "/api/v2/vehicle-profiles/sync"}, {"GET", "/api/v2/admin/users"}, {"GET", "/api/v2/monitor/../admin/users"}, {"GET", "/api/v2/vehicles/VIN%2f123/profile"}, {"GET", "/unknown"}} {
|
||||
if w := request(route.method, route.path, admin); w.Code != 403 {
|
||||
t.Errorf("unexpected write/unknown access %s: %d", route.path, w.Code)
|
||||
}
|
||||
}
|
||||
for _, p := range []platform.Principal{{Role: "customer", AuthProvider: "oneos"}, {Role: "admin", AuthProvider: "local", PermissionKeys: []string{"*:*:*"}}, {Role: "admin", AuthProvider: "oneos"}} {
|
||||
if w := request("GET", "/api/v2/monitor/workspace", p); w.Code != 403 {
|
||||
t.Error("non-authoritative admin accepted")
|
||||
}
|
||||
}
|
||||
if calls.Load() != count {
|
||||
t.Fatal("denied request reached upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevDataProxyDoesNotFollowRedirectsOrFallBack(t *testing.T) {
|
||||
for _, status := range []int{302, 401, 403, 500} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "http://evil.invalid")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte("private upstream diagnostic"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "server-token-123456"})
|
||||
r := httptest.NewRequest("GET", "/api/v2/monitor/summary", nil)
|
||||
r = r.WithContext(platform.WithPrincipal(r.Context(), platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}))
|
||||
w := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(w, r)
|
||||
if w.Code != 502 || strings.Contains(w.Body.String(), "private upstream") || w.Header().Get("Location") != "" {
|
||||
t.Fatalf("unsafe error %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevDataProxyIsolationConfiguration(t *testing.T) {
|
||||
base := config.Config{OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce", OneOSDevDataProxyURL: "http://127.0.0.1:20420", OneOSDevDataProxyToken: "server-token-123456", AMapWebJSKey: "public-key", AMapServiceHost: "/_AMapService"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*config.Config){func(c *config.Config) { c.OneOSDevIntegration = false }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://localhost:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://192.0.2.1:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20400" }, func(c *config.Config) { c.OneOSDevDataProxyURL += "/api" }, func(c *config.Config) { c.OneOSDevDataProxyToken = "" }, func(c *config.Config) { c.MySQLDSN = "external" }, func(c *config.Config) { c.HistoryCleanupAutomation = true }, func(c *config.Config) { c.AMapServiceHost = "https://evil.invalid" }} {
|
||||
c := base
|
||||
mutate(&c)
|
||||
if ValidateLocalIsolation(c) == nil {
|
||||
t.Fatal("unsafe dev proxy configuration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSRealDataAdminSessionKeepsOrdinaryUsersScoped(t *testing.T) {
|
||||
var admin atomic.Bool
|
||||
admin.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
perms := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
if admin.Load() {
|
||||
perms = []string{"*:*:*"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": perms}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true, devIntegration: true, devDataProxy: true}
|
||||
r := httptest.NewRequest("GET", "/api/v2/session", nil)
|
||||
p, _, err := a.resolve(r.Context(), "original-token-123456789", "web")
|
||||
if err != nil || p.Role != "admin" || p.AuthorizationStatus != "ready" || !p.ReadOnly || p.DataSource != "ecs-readonly" {
|
||||
t.Fatalf("real-data admin not ready: %+v %v", p, err)
|
||||
}
|
||||
admin.Store(false)
|
||||
p, _, err = a.resolve(r.Context(), "original-token-123456789", "web")
|
||||
if err != nil || p.Role != "customer" || p.AuthorizationStatus != "asset-catalog" || p.DataSource != "" {
|
||||
t.Fatalf("ordinary identity elevated: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOneOSLocalBackendRequiresExplicitLoopbackStores(t *testing.T) {
|
||||
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", RedisAddr: "127.0.0.1:20479", TDengineDSN: "test:test@ws(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*config.Config){
|
||||
func(c *config.Config) { c.MySQLDSN = "test:test@tcp(prod.example:3306)/vehicle" },
|
||||
func(c *config.Config) { c.RedisAddr = "prod.example:6379" },
|
||||
func(c *config.Config) { c.TDengineDSN = "test:test@ws(prod.example:6041)/" },
|
||||
func(c *config.Config) { c.OneOSVehicleQueryScope = false },
|
||||
func(c *config.Config) { c.AuthMode = "disabled" },
|
||||
func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
|
||||
func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
func(c *config.Config) { c.DailyGeographyEnabled = true },
|
||||
func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20420" },
|
||||
} {
|
||||
candidate := cfg
|
||||
mutate(&candidate)
|
||||
if ValidateLocalIsolation(candidate) == nil {
|
||||
t.Fatal("unsafe backend configuration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalOneOSProductionOriginAllowlist(t *testing.T) {
|
||||
for _, value := range []string{"https://lnoneos.com/prod-api", "https://lnoneos.com/prod-api/"} {
|
||||
if !validOneOSDevBaseURL(value) {
|
||||
t.Fatalf("production origin rejected: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"http://lnoneos.com/prod-api", "https://lnoneos.com.evil.invalid/prod-api", "https://lnoneos.com/prod-api?x=1", "https://lnoneos.com:443/prod-api"} {
|
||||
if validOneOSDevBaseURL(value) {
|
||||
t.Fatalf("unsafe origin accepted: %s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLocalBackendRESTRequiresLiteralLoopbackTunnel(t *testing.T) {
|
||||
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20301", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", TDengineDriver: "taosRestful", TDengineDSN: "test:test@http(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dsn := range []string{
|
||||
"test:test@http(prod.example:6041)/",
|
||||
"test:test@http(localhost:20441)/",
|
||||
"test:test@http(0.0.0.0:20441)/",
|
||||
"test:test@http(127.0.0.1:0)/",
|
||||
"test:test@ws(127.0.0.1:20441)/",
|
||||
"invalid",
|
||||
} {
|
||||
candidate := cfg
|
||||
candidate.TDengineDSN = dsn
|
||||
if ValidateLocalIsolation(candidate) == nil {
|
||||
t.Fatalf("unsafe REST tunnel accepted: %s", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/taosdata/driver-go/v3/taosRestful"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
// ValidateLocalIsolation runs before constructing any database, external client,
|
||||
// scheduler or listener. It is an opt-in development boundary, not an auth bypass.
|
||||
func validOneOSDevBaseURL(value string) bool {
|
||||
return value == "https://dev-oneos.lnh2e.com/prod-api" || value == "https://dev-oneos.lnh2e.com/prod-api/" || value == "https://lnoneos.com/prod-api" || value == "https://lnoneos.com/prod-api/"
|
||||
}
|
||||
|
||||
func ValidateLocalIsolation(cfg config.Config) error {
|
||||
if err := validateOneOSDevDataProxy(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
|
||||
return fmt.Errorf("ONEOS_LOCAL_ONLY and ONEOS_DEV_INTEGRATION are mutually exclusive")
|
||||
}
|
||||
if cfg.OneOSLocalBackend && !cfg.OneOSDevIntegration {
|
||||
return fmt.Errorf("local backend requires dev integration")
|
||||
}
|
||||
if !cfg.OneOSLocalOnly && !cfg.OneOSDevIntegration {
|
||||
return nil
|
||||
}
|
||||
if cfg.OneOSDevIntegration {
|
||||
if !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
|
||||
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires an explicitly allowed OneOS HTTPS origin")
|
||||
}
|
||||
if !validOneOSOrigin(cfg.OneOSPublicOrigin, true) {
|
||||
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires a literal loopback public origin")
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(cfg.HTTPAddr)
|
||||
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
|
||||
return fmt.Errorf("local OneOS testing requires an explicit loopback HTTP_ADDR")
|
||||
}
|
||||
if cfg.OneOSLocalBackend {
|
||||
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || !cfg.OneOSVehicleQueryScope || cfg.DataMode != "production" || cfg.AuthMode != "enforce" || cfg.OneOSDevDataProxyURL != "" {
|
||||
return fmt.Errorf("local backend requires dev OneOS, vehicle-query scope, production store and enforced authentication")
|
||||
}
|
||||
db, err := mysql.ParseDSN(cfg.MySQLDSN)
|
||||
if err != nil || db.Net != "tcp" || !loopbackAddress(db.Addr) {
|
||||
return fmt.Errorf("local backend requires a loopback MySQL tunnel")
|
||||
}
|
||||
if cfg.RedisAddr != "" && !loopbackAddress(cfg.RedisAddr) {
|
||||
return fmt.Errorf("local backend requires a loopback Redis tunnel")
|
||||
}
|
||||
if cfg.TDengineDSN != "" {
|
||||
if cfg.TDengineDriver == "taosRestful" {
|
||||
rest, err := taosRestful.ParseDSN(cfg.TDengineDSN)
|
||||
if err != nil || rest.Net != "http" || rest.Port < 1 || rest.Port > 65535 || !loopbackAddress(net.JoinHostPort(rest.Addr, fmt.Sprint(rest.Port))) {
|
||||
return fmt.Errorf("local backend requires a loopback TDengine REST tunnel")
|
||||
}
|
||||
} else {
|
||||
if cfg.TDengineDriver != "" && cfg.TDengineDriver != "taosWS" {
|
||||
return fmt.Errorf("local backend requires a supported TDengine driver")
|
||||
}
|
||||
marker := strings.LastIndex(cfg.TDengineDSN, "@ws(")
|
||||
if marker < 0 {
|
||||
return fmt.Errorf("local backend requires a TDengine websocket tunnel")
|
||||
}
|
||||
address := strings.SplitN(cfg.TDengineDSN[marker+4:], ")", 2)
|
||||
if len(address) != 2 || !loopbackAddress(address[0]) {
|
||||
return fmt.Errorf("local backend requires a loopback TDengine tunnel")
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled || cfg.CapacityCheckBin != "" || cfg.AlertStreamMode != "disabled" || len(cfg.AlertStreamKafkaBrokers) != 0 || cfg.AlertNotificationSMSURL != "" || cfg.AlertNotificationEmailURL != "" || cfg.AlertNotificationWeComURL != "" {
|
||||
return fmt.Errorf("local backend must not start production workers or notification delivery")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
|
||||
return fmt.Errorf("local OneOS testing requires DATA_MODE=mock and AUTH_MODE=enforce")
|
||||
}
|
||||
values := []string{
|
||||
cfg.MySQLDSN, cfg.RedisAddr, cfg.TDengineDSN, cfg.CapacityCheckBin,
|
||||
cfg.AMapAPIKey, cfg.AMapSecurityCode,
|
||||
cfg.AuthToken, cfg.AuthTokensJSON, cfg.BootstrapAdminPassword,
|
||||
cfg.AlertNotificationSMSURL, cfg.AlertNotificationEmailURL, cfg.AlertNotificationWeComURL,
|
||||
cfg.OneOSIntrospectionURL,
|
||||
}
|
||||
if cfg.OneOSDevDataProxyURL == "" && (cfg.AMapWebJSKey != "" || cfg.AMapServiceHost != "") {
|
||||
return fmt.Errorf("isolated OneOS testing rejects external map configuration")
|
||||
}
|
||||
if cfg.OneOSDevDataProxyURL != "" && cfg.AMapServiceHost != "" && cfg.AMapServiceHost != "/_AMapService" {
|
||||
return fmt.Errorf("real-data development requires the local map proxy path")
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return fmt.Errorf("local OneOS testing rejects external data, legacy auth and notification configuration")
|
||||
}
|
||||
}
|
||||
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled ||
|
||||
(cfg.AlertStreamMode != "" && cfg.AlertStreamMode != "disabled") || len(cfg.AlertStreamKafkaBrokers) != 0 {
|
||||
return fmt.Errorf("local OneOS testing rejects external workers and legacy SSO")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loopbackAddress(address string) bool {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
ip := net.ParseIP(host)
|
||||
return err == nil && ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func withOneOSLocalIsolationHeaders(next http.Handler, realData ...bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The local UI must not load map SDKs, analytics, APIs or images remotely.
|
||||
// Existing index.html contains an inline boot/error indicator. Inline code
|
||||
// remains local; every external resource and connection is still excluded.
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
||||
if len(realData) > 0 && realData[0] {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://webapi.amap.com https://jsapi-service.amap.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.amap.com https://*.autonavi.com; font-src 'self' data:; connect-src 'self' https://*.amap.com https://*.autonavi.com; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
||||
}
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSLocalIsolationRejectsExternalConfiguration(t *testing.T) {
|
||||
base := config.Config{OneOSLocalOnly: true, HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]func(*config.Config){
|
||||
"wildcard listen": func(c *config.Config) { c.HTTPAddr = ":20400" },
|
||||
"hostname listen": func(c *config.Config) { c.HTTPAddr = "localhost:20400" },
|
||||
"production mode": func(c *config.Config) { c.DataMode = "production" },
|
||||
"auth bypass": func(c *config.Config) { c.AuthMode = "disabled" },
|
||||
"database": func(c *config.Config) { c.MySQLDSN = "must-not-be-opened" },
|
||||
"redis": func(c *config.Config) { c.RedisAddr = "192.0.2.1:6379" },
|
||||
"tdengine": func(c *config.Config) { c.TDengineDSN = "must-not-be-opened" },
|
||||
"notification": func(c *config.Config) { c.AlertNotificationSMSURL = "https://example.invalid" },
|
||||
"map": func(c *config.Config) { c.AMapWebJSKey = "local-must-not-load-sdk" },
|
||||
"legacy credential": func(c *config.Config) { c.AuthToken = "must-not-bypass-oneos" },
|
||||
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
"kafka": func(c *config.Config) { c.AlertStreamKafkaBrokers = []string{"192.0.2.1:9092"} },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := base
|
||||
mutate(&cfg)
|
||||
if ValidateLocalIsolation(cfg) == nil {
|
||||
t.Fatal("unsafe local configuration accepted")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
NewServer(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v2/session", nil))
|
||||
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "LOCAL_ISOLATION_REQUIRED") {
|
||||
t.Fatalf("unexpected response %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLocalBrowserCannotFetchRemoteResources(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
withOneOSLocalIsolationHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
for _, requirement := range []string{"connect-src 'self'", "script-src 'self'", "frame-src 'none'", "form-action 'self'"} {
|
||||
if !strings.Contains(csp, requirement) {
|
||||
t.Fatalf("missing %s", requirement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevIntegrationPreservesLocalIsolation(t *testing.T) {
|
||||
base := config.Config{OneOSDevIntegration: true, HTTPAddr: "127.0.0.1:20400", OneOSPublicOrigin: "http://127.0.0.1:20400", OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", DataMode: "mock", AuthMode: "enforce"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*config.Config){
|
||||
"fixture conflict": func(c *config.Config) { c.OneOSLocalOnly = true },
|
||||
"other host": func(c *config.Config) { c.OneOSProfileBaseURL = "https://evil.invalid/prod-api" },
|
||||
"other prefix": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com/other" },
|
||||
"insecure": func(c *config.Config) { c.OneOSProfileBaseURL = "http://dev-oneos.lnh2e.com/prod-api" },
|
||||
"credentials": func(c *config.Config) { c.OneOSProfileBaseURL = "https://user@dev-oneos.lnh2e.com/prod-api" },
|
||||
"port": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com:443/prod-api" },
|
||||
"nonlocal listen": func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
|
||||
"nonlocal public": func(c *config.Config) { c.OneOSPublicOrigin = "https://dev-oneos.lnh2e.com" },
|
||||
"external db": func(c *config.Config) { c.MySQLDSN = "blocked" },
|
||||
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
"real business": func(c *config.Config) { c.DataMode = "production" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := base
|
||||
mutate(&cfg)
|
||||
if ValidateLocalIsolation(cfg) == nil {
|
||||
t.Fatal("unsafe dev integration accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// oneOSCredential carries the real upstream login token. It is never derived
|
||||
// from, or interchangeable with, the restricted profile-jump token.
|
||||
type oneOSCredential struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type oneOSCurrentUser struct {
|
||||
Profile oneOSProfile
|
||||
Permissions []string
|
||||
Roles []string
|
||||
Status string
|
||||
}
|
||||
|
||||
type oneOSVehicleGrant struct {
|
||||
VIN string `json:"vin"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
}
|
||||
|
||||
type oneOSVehicleScope struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
UserID string `json:"userId"`
|
||||
PermissionVersion string `json:"permissionVersion"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Vehicles []oneOSVehicleGrant `json:"vehicles"`
|
||||
}
|
||||
|
||||
func validateOneOSConfiguredPath(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "?#\\\r\n") {
|
||||
return errors.New("invalid OneOS path")
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
if err != nil || u.IsAbs() || u.Host != "" || u.RawPath != "" || u.Path != path || strings.Contains(path, "..") || strings.Contains(strings.ToLower(path), "%2f") || strings.Contains(strings.ToLower(path), "%2e") {
|
||||
return errors.New("invalid OneOS path")
|
||||
}
|
||||
if strings.Contains(path, "profile-jump") {
|
||||
return errors.New("profile-jump is not a login-token exchange")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) ExchangeLoginTicket(ctx context.Context, code string) (oneOSCredential, error) {
|
||||
if c == nil || c.loginTicketExchangePath == "" {
|
||||
return oneOSCredential{}, errors.New("OneOS login-ticket exchange is disabled")
|
||||
}
|
||||
if !validOneOSOpaqueToken(code) {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login ticket")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(struct {
|
||||
Code string `json:"code"`
|
||||
}{code})
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ClientID string `json:"clientId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodPost, c.loginTicketExchangePath, body, c.loginTicketServiceToken, &result); err != nil {
|
||||
return oneOSCredential{}, err
|
||||
}
|
||||
if result.TokenType != "Bearer" || result.ClientID == "" || result.ClientID != c.passwordClientID || !validOneOSLoginToken(result.AccessToken) || !result.ExpiresAt.After(time.Now()) {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login-ticket response")
|
||||
}
|
||||
expiry := result.ExpiresAt
|
||||
if ceiling := time.Now().Add(30 * time.Minute); expiry.After(ceiling) {
|
||||
expiry = ceiling
|
||||
}
|
||||
return oneOSCredential{AccessToken: result.AccessToken, ClientID: result.ClientID, ExpiresAt: expiry}, nil
|
||||
}
|
||||
|
||||
func validOneOSLoginToken(token string) bool {
|
||||
return len(token) >= 16 && len(token) <= 4096 && !strings.ContainsAny(token, "\r\n\t ")
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) CurrentUser(ctx context.Context, token, clientID string) (oneOSCurrentUser, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var raw struct {
|
||||
User struct {
|
||||
UserID json.RawMessage `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"userName"`
|
||||
Nickname string `json:"nickName"`
|
||||
DeptID json.RawMessage `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
Status string `json:"status"`
|
||||
} `json:"user"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/getInfo", nil, token, clientID, &raw); err != nil {
|
||||
return oneOSCurrentUser{}, err
|
||||
}
|
||||
userID, err := oneOSIDString(raw.User.UserID)
|
||||
if err != nil {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS user identity")
|
||||
}
|
||||
deptID, err := oneOSIDString(raw.User.DeptID)
|
||||
if err != nil {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS department identity")
|
||||
}
|
||||
if userID == "" || raw.User.TenantID != c.allowedTenant || raw.User.Status != "0" {
|
||||
return oneOSCurrentUser{}, errors.New("OneOS user or tenant inactive")
|
||||
}
|
||||
return oneOSCurrentUser{Profile: oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, Permissions: raw.Permissions, Roles: raw.Roles, Status: raw.User.Status}, nil
|
||||
}
|
||||
|
||||
func oneOSIDString(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", nil
|
||||
}
|
||||
var s string
|
||||
if raw[0] == '"' {
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(raw, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := n.Int64(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return n.String(), nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) VehicleScope(ctx context.Context, token, clientID string) (oneOSVehicleScope, error) {
|
||||
if c == nil || c.vehicleScopePath == "" {
|
||||
return oneOSVehicleScope{}, errors.New("OneOS vehicle-scope endpoint is disabled")
|
||||
}
|
||||
if !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var scope oneOSVehicleScope
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, c.vehicleScopePath, nil, token, clientID, &scope); err != nil {
|
||||
return oneOSVehicleScope{}, err
|
||||
}
|
||||
if scope.TenantID != c.allowedTenant || strings.TrimSpace(scope.UserID) == "" || strings.TrimSpace(scope.PermissionVersion) == "" || !scope.ExpiresAt.After(time.Now()) || scope.ExpiresAt.After(time.Now().Add(65*time.Second)) {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle scope")
|
||||
}
|
||||
if len(scope.Vehicles) > 100000 {
|
||||
return oneOSVehicleScope{}, errors.New("OneOS vehicle scope too large")
|
||||
}
|
||||
for _, grant := range scope.Vehicles {
|
||||
if strings.TrimSpace(grant.VIN) == "" || grant.ValidFrom.IsZero() || grant.ValidFrom.After(time.Now()) || (grant.ValidTo != nil && !grant.ValidTo.After(grant.ValidFrom)) {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle grant")
|
||||
}
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) Logout(ctx context.Context, token, clientID string) error {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
return c.doAuthenticatedJSON(ctx, http.MethodPost, "/auth/logout", []byte("{}"), token, clientID, nil)
|
||||
}
|
||||
|
||||
// FetchOwnProfile demonstrates a fixed, trusted OneOS API call with the same
|
||||
// upstream token. It does not proxy arbitrary paths or return extra PII.
|
||||
func (c *oneOSProfileClient) FetchOwnProfile(ctx context.Context, token, clientID string) (oneOSProfile, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var raw struct {
|
||||
User struct {
|
||||
UserID json.RawMessage `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"userName"`
|
||||
Nickname string `json:"nickName"`
|
||||
DeptID json.RawMessage `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/profile", nil, token, clientID, &raw); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
userID, err := oneOSIDString(raw.User.UserID)
|
||||
if err != nil {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
deptID, err := oneOSIDString(raw.User.DeptID)
|
||||
if err != nil {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
if userID == "" || raw.User.TenantID != c.allowedTenant {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
return oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) doAuthenticatedJSON(ctx context.Context, method, path string, body []byte, token, clientID string, target any) error {
|
||||
if !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return errors.New("invalid OneOS credential")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("invalid OneOS request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("ClientID", clientID)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("OneOS API unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("OneOS API rejected request: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[json.RawMessage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
|
||||
return errors.New("OneOS API rejected request")
|
||||
}
|
||||
if target != nil {
|
||||
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, target); err != nil {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSLoginTransportLocalFixture(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
var paths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
if r.URL.Path != "/auth/inner/v1/sso/vehicle-platform/token-exchange" && (r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a") {
|
||||
t.Errorf("missing upstream credential on %s", r.URL.Path)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/auth/inner/v1/sso/vehicle-platform/token-exchange":
|
||||
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
|
||||
t.Error("bad ticket exchange request")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": "upstream-token-123456", "tokenType": "Bearer", "clientId": "client-a", "expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}})
|
||||
case "/system/user/getInfo":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":"42","tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"],"roles":["operator"]}}`))
|
||||
case "/asset/vehicle-platform/scope":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"tenantId": "tenant-a", "userId": "42", "permissionVersion": "v1", "expiresAt": time.Now().Add(50 * time.Second).UTC().Format(time.RFC3339), "vehicles": []map[string]any{{"vin": "VIN123", "validFrom": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)}}}})
|
||||
case "/system/user/profile":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","deptId":7}}}`))
|
||||
case "/auth/logout":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":null}`))
|
||||
default:
|
||||
t.Errorf("unexpected endpoint %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordClientID: "client-a", OneOSLocalOnly: true, OneOSLoginTicketExchangePath: "/auth/inner/v1/sso/vehicle-platform/token-exchange", OneOSLoginTicketServiceToken: strings.Repeat("s", 32), OneOSVehicleScopePath: "/asset/vehicle-platform/scope"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := client.ExchangeLoginTicket(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" {
|
||||
t.Fatal("incorrect upstream credential")
|
||||
}
|
||||
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Profile.UserID != "42" || user.Profile.DeptID != "7" || len(user.Permissions) != 1 || user.Status != "0" {
|
||||
t.Fatalf("wrong user: %+v", user)
|
||||
}
|
||||
scope, err := client.VehicleScope(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scope.UserID != "42" || len(scope.Vehicles) != 1 || scope.Vehicles[0].VIN != "VIN123" {
|
||||
t.Fatalf("wrong scope: %+v", scope)
|
||||
}
|
||||
profile, err := client.FetchOwnProfile(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || profile.UserID != "42" {
|
||||
t.Fatalf("wrong own profile: %+v %v", profile, err)
|
||||
}
|
||||
if err := client.Logout(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 5 {
|
||||
t.Fatalf("wrong API call count: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLoginTransportRejectsUnsafeOriginsAndPaths(t *testing.T) {
|
||||
base := config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant", OneOSLocalOnly: true, OneOSPasswordClientID: "client"}
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("remote HTTP accepted")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "https://oneos.example"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("remote HTTPS accepted in local-only mode")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "http://127.0.0.1:1234"
|
||||
base.OneOSLoginTicketExchangePath = "/auth/inner/v1/sso/vehicle-platform/token-exchange"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("missing service credential accepted")
|
||||
}
|
||||
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 31)
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("short service credential accepted")
|
||||
}
|
||||
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 32)
|
||||
base.OneOSLoginTicketExchangePath = "/auth/sso/profile-jump/exchange"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("profile-jump endpoint accepted as login exchange")
|
||||
}
|
||||
base.OneOSLoginTicketExchangePath = "//attacker.example/path"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("network path accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSCurrentUserRejectsDisabledAndOtherTenant(t *testing.T) {
|
||||
for _, tc := range []struct{ tenant, status string }{{"other", "0"}, {"tenant-a", "1"}} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": 42, "tenantId": tc.tenant, "status": tc.status}}})
|
||||
}))
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSLocalOnly: true, OneOSPasswordClientID: "client-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.CurrentUser(context.Background(), "upstream-token-123456", "client-a"); err == nil {
|
||||
t.Fatalf("accepted tenant=%s status=%s", tc.tenant, tc.status)
|
||||
}
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSStatisticsOnlyUserCanLoadMileageVehiclePickers(t *testing.T) {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:statistics:view", "vehiclePlatform:statistics:read"}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "18", "tenantId": "000000", "userName": "statistics-only", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LB9A32A24R0LS1426"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
|
||||
request := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer statistics-user-token")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, tc := range []struct{ method, path, body string }{
|
||||
{"GET", "/api/v2/statistics/vehicle-options?limit=20&bindingStatus=bound", ""},
|
||||
{"GET", "/api/v2/statistics/vehicles?limit=12", ""},
|
||||
{"POST", "/api/v2/statistics/vehicle-resolve", `{"keywords":["LB9A32A24R0LS1426"],"limit":200}`},
|
||||
} {
|
||||
w := request(tc.method, tc.path, tc.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s status=%d body=%s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Data.Items) == 0 {
|
||||
t.Fatalf("missing authorized vehicle: %s", w.Body.String())
|
||||
}
|
||||
for _, row := range response.Data.Items {
|
||||
if row["vin"] != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("unauthorized VIN: %+v", row)
|
||||
}
|
||||
for key := range row {
|
||||
if key != "vin" && key != "plate" && key != "brandName" && key != "modelName" {
|
||||
t.Fatalf("excess field %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles/coverage", "/api/vehicles", "/api/v2/monitor/workspace"} {
|
||||
w := request("GET", path, "")
|
||||
if w.Code != 403 || !strings.Contains(w.Body.String(), "查询权限") || strings.Contains(w.Body.String(), "授权已失效") {
|
||||
t.Fatalf("permission error not preserved: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:view"}
|
||||
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
|
||||
t.Fatalf("revoked read access: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:read"}
|
||||
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
|
||||
t.Fatalf("revoked menu access: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type oneOSCaptcha struct {
|
||||
Enabled bool `json:"captchaEnabled"`
|
||||
UUID string `json:"uuid"`
|
||||
Image string `json:"img"`
|
||||
}
|
||||
|
||||
type oneOSPasswordLogin struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}
|
||||
|
||||
func parseOneOSRSAPublicKey(value string) (*rsa.PublicKey, error) {
|
||||
return parseOneOSRSAPublicKeyForMode(value, false)
|
||||
}
|
||||
|
||||
// The dev OneOS web bundle currently publishes a legacy 512-bit RSA key.
|
||||
// Accepting it requires the separate, fixed-origin dev integration guard in
|
||||
// the caller. Normal configurations retain the 2048-bit minimum.
|
||||
func parseOneOSRSAPublicKeyForMode(value string, devIntegration bool) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(value))
|
||||
var der []byte
|
||||
if block != nil {
|
||||
der = block.Bytes
|
||||
} else {
|
||||
var err error
|
||||
der, err = base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid RSA public key encoding")
|
||||
}
|
||||
}
|
||||
minBits := 2048
|
||||
if devIntegration {
|
||||
minBits = 512
|
||||
}
|
||||
if key, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||||
if public, ok := key.(*rsa.PublicKey); ok && public.N.BitLen() >= minBits {
|
||||
return public, nil
|
||||
}
|
||||
}
|
||||
if public, err := x509.ParsePKCS1PublicKey(der); err == nil && public.N.BitLen() >= minBits {
|
||||
return public, nil
|
||||
}
|
||||
return nil, errors.New("unsupported RSA public key")
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) FetchCaptcha(ctx context.Context) (oneOSCaptcha, error) {
|
||||
if c == nil || !c.passwordEnabled {
|
||||
return oneOSCaptcha{}, errors.New("OneOS password login is disabled")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var captcha oneOSCaptcha
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/auth/code", nil, "", &captcha); err != nil {
|
||||
return oneOSCaptcha{}, err
|
||||
}
|
||||
if captcha.Enabled && (captcha.UUID == "" || captcha.Image == "" || len(captcha.Image) > 50<<10) {
|
||||
return oneOSCaptcha{}, errors.New("invalid OneOS captcha response")
|
||||
}
|
||||
return captcha, nil
|
||||
}
|
||||
|
||||
// AuthenticatePassword follows the OneOS browser's RSA/AES request protocol
|
||||
// and returns the actual OneOS login credential for online validation.
|
||||
func (c *oneOSProfileClient) AuthenticatePassword(ctx context.Context, login oneOSPasswordLogin) (oneOSCredential, error) {
|
||||
if c == nil || !c.passwordEnabled {
|
||||
return oneOSCredential{}, errors.New("OneOS password login is disabled")
|
||||
}
|
||||
if strings.TrimSpace(login.Username) == "" || login.Password == "" || len(login.Username) > 256 || len(login.Password) > 1024 {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS credentials")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
plain, _ := json.Marshal(struct {
|
||||
ClientID string `json:"clientId"`
|
||||
GrantType string `json:"grantType"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}{c.passwordClientID, "password", c.allowedTenant, login.Username, login.Password, login.Code, login.UUID})
|
||||
seed := make([]byte, 24)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
key := []byte(base64.RawURLEncoding.EncodeToString(seed)) // 32 printable ASCII bytes, as in the OneOS web client.
|
||||
// JSEncrypt encrypts the base64 form of the ASCII AES key with RSA PKCS#1 v1.5.
|
||||
wrapped, err := rsa.EncryptPKCS1v15(rand.Reader, c.passwordPublicKey, []byte(base64.StdEncoding.EncodeToString(key)))
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
ciphertext, err := oneOSAesECBEncrypt(plain, key)
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/auth/login", strings.NewReader(base64.StdEncoding.EncodeToString(ciphertext)))
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login request")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("ClientID", c.passwordClientID)
|
||||
request.Header.Set("encrypt-key", base64.StdEncoding.EncodeToString(wrapped))
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS password login unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return oneOSCredential{}, errors.New("OneOS password login rejected")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpireIn int64 `json:"expire_in"`
|
||||
}]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || !validOneOSLoginToken(envelope.Data.AccessToken) || envelope.Data.ClientID != c.passwordClientID || envelope.Data.ExpireIn == 0 || envelope.Data.ExpireIn < -1 {
|
||||
return oneOSCredential{}, errors.New("OneOS password login rejected")
|
||||
}
|
||||
// OneOS can configure indefinite timeout (-1). This local credential horizon
|
||||
// stays bounded; online CurrentUser checks remain mandatory on use.
|
||||
ttl := envelope.Data.ExpireIn
|
||||
if ttl == -1 || ttl > 1800 {
|
||||
ttl = 1800
|
||||
}
|
||||
return oneOSCredential{AccessToken: envelope.Data.AccessToken, ClientID: envelope.Data.ClientID, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}, nil
|
||||
}
|
||||
|
||||
func oneOSAesECBEncrypt(plain, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
padding := aes.BlockSize - len(plain)%aes.BlockSize
|
||||
plain = append(bytes.Clone(plain), bytes.Repeat([]byte{byte(padding)}, padding)...)
|
||||
result := make([]byte, len(plain))
|
||||
for offset := 0; offset < len(plain); offset += aes.BlockSize {
|
||||
block.Encrypt(result[offset:offset+aes.BlockSize], plain[offset:offset+aes.BlockSize])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSRSAPublicKeyLegacyDevModeIsExplicit(t *testing.T) {
|
||||
legacy := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
|
||||
legacy.N.SetBit(legacy.N, 0, 1)
|
||||
der, err := x509.MarshalPKIXPublicKey(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, encoded := range []string{
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})),
|
||||
base64.StdEncoding.EncodeToString(der),
|
||||
} {
|
||||
if _, err := parseOneOSRSAPublicKey(encoded); err == nil {
|
||||
t.Fatal("normal parser accepted 512-bit key")
|
||||
}
|
||||
parsed, err := parseOneOSRSAPublicKeyForMode(encoded, true)
|
||||
if err != nil || parsed.N.BitLen() != 512 {
|
||||
t.Fatalf("dev parser rejected 512-bit key: %v", err)
|
||||
}
|
||||
}
|
||||
tooSmall := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 510), E: 65537}
|
||||
tooSmall.N.SetBit(tooSmall.N, 0, 1)
|
||||
smallDER, err := x509.MarshalPKIXPublicKey(tooSmall)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(smallDER), true); err == nil {
|
||||
t.Fatal("dev parser accepted sub-512-bit key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSPasswordLegacy512EncryptsOnlyWithGoDevOverride(t *testing.T) {
|
||||
t.Setenv("GODEBUG", "")
|
||||
legacyPublic := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
|
||||
legacyPublic.N.SetBit(legacyPublic.N, 0, 1)
|
||||
if _, err := rsa.EncryptPKCS1v15(rand.Reader, legacyPublic, []byte("probe")); err == nil {
|
||||
t.Fatal("Go unexpectedly accepted 512-bit RSA without override")
|
||||
}
|
||||
t.Setenv("GODEBUG", "rsa1024min=0")
|
||||
private, err := rsa.GenerateKey(rand.Reader, 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
public, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(der), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/auth/login" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
base64Key, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(string(base64Key))
|
||||
if err != nil || len(key) != 32 {
|
||||
t.Error("invalid AES key")
|
||||
return
|
||||
}
|
||||
cipherBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(string(cipherBody))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
plain = plain[:len(plain)-int(plain[len(plain)-1])]
|
||||
var login map[string]string
|
||||
if err := json.Unmarshal(plain, &login); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if login["clientId"] != "dev-client" || login["password"] != "local-fixture-password" {
|
||||
t.Error("incorrect decrypted login fields")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"local-dev-token-123456","client_id":"dev-client","expire_in":120}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, requestTimeout: time.Second, client: &http.Client{Timeout: time.Second}, passwordEnabled: true, passwordClientID: "dev-client", passwordPublicKey: public, allowedTenant: "tenant-a"}
|
||||
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "local-fixture-password"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "local-dev-token-123456" || credential.ClientID != "dev-client" {
|
||||
t.Fatal("incorrect legacy credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSPasswordUsesEncryptedLoginAndCurrentUser(t *testing.T) {
|
||||
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/auth/code":
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("captcha request has bearer")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"captchaEnabled":true,"uuid":"u","img":"image"}}`)
|
||||
case "/auth/login":
|
||||
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
keyBase64, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(string(keyBase64))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
encoded, _ := io.ReadAll(r.Body)
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(string(encoded))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
plain = plain[:len(plain)-int(plain[len(plain)-1])]
|
||||
var login map[string]string
|
||||
if err := json.Unmarshal(plain, &login); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if login["clientId"] != "client-a" || login["grantType"] != "password" || login["tenantId"] != "tenant-a" || login["password"] != "secret" || login["code"] != "1234" || login["uuid"] != "u" {
|
||||
t.Errorf("bad login request: %v", login)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"upstream-token-123456","client_id":"client-a","expire_in":3600}}`)
|
||||
case "/system/user/getInfo":
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
|
||||
t.Error("wrong current-user token")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"]}}`)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordEnabled: true, OneOSPasswordClientID: "client-a", OneOSPasswordRSAPublicKey: publicPEM})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.client = server.Client()
|
||||
captcha, err := client.FetchCaptcha(context.Background())
|
||||
if err != nil || !captcha.Enabled || captcha.UUID != "u" {
|
||||
t.Fatalf("captcha=%+v err=%v", captcha, err)
|
||||
}
|
||||
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "secret", Code: "1234", UUID: "u"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" || credential.ExpiresAt.After(time.Now().Add(31*time.Minute)) {
|
||||
t.Fatal("bad credential")
|
||||
}
|
||||
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := user.Profile
|
||||
if !bytes.Equal([]byte(profile.UserID), []byte("42")) || profile.TenantID != "tenant-a" || profile.Issuer != "issuer" || len(user.Permissions) != 1 {
|
||||
t.Fatalf("bad current user: %+v", user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validOneOSOrigin(value string, local bool) bool {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return false
|
||||
}
|
||||
if local {
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
return ip != nil && ip.IsLoopback() && (u.Scheme == "http" || u.Scheme == "https")
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
const oneOSHandoffCookie = "oneos_handoff"
|
||||
|
||||
type oneOSPendingLogin struct {
|
||||
credential oneOSCredential
|
||||
profile oneOSProfile
|
||||
csrf string
|
||||
expires time.Time
|
||||
}
|
||||
type oneOSProfileAuth struct {
|
||||
client *oneOSProfileClient
|
||||
store *authStore
|
||||
origin, issuer, tenant string
|
||||
passwordEnabled, ssoEnabled, scopeEnabled, localOnly bool
|
||||
devIntegration bool
|
||||
devDataProxy bool
|
||||
localBackend, vehicleQueryScope bool
|
||||
scopeMu sync.Mutex
|
||||
queryScopes map[[32]byte]*oneOSQueryScopeEntry
|
||||
mu sync.Mutex
|
||||
pendingLogins map[[32]byte]oneOSPendingLogin
|
||||
users map[string]authUser
|
||||
}
|
||||
type oneOSLoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) cookie(w http.ResponseWriter, value string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{Name: oneOSHandoffCookie, Value: value, Path: "/api/v2/auth/oneos", HttpOnly: true, Secure: strings.HasPrefix(a.origin, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
||||
}
|
||||
func oneOSAuthError(w http.ResponseWriter, r *http.Request, status int, code string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
message := "OneOS 登录暂不可用或授权已失效"
|
||||
if code == "ONEOS_PERMISSION_DENIED" {
|
||||
message = "当前 OneOS 账号缺少此功能的查询权限,请联系管理员开通后刷新校验"
|
||||
}
|
||||
httpx.WriteError(w, status, code, message, "", requestTraceID(r))
|
||||
}
|
||||
func (a *oneOSProfileAuth) handle(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.URL.Path == "/api/v2/auth/logout" && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
if err := a.client.Logout(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID")); err != nil {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_LOGOUT_FAILED")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
return true
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/oneos/me" || r.URL.Path == "/api/v2/auth/oneos/vehicles" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v2/auth/oneos/") {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/oneos/callback":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
// Never exchange profile-jump credentials here: they are not login tokens.
|
||||
var err error
|
||||
if !a.ssoEnabled {
|
||||
err = errOneOSAuthority
|
||||
} else {
|
||||
var credential oneOSCredential
|
||||
credential, err = a.client.ExchangeLoginTicket(r.Context(), r.URL.Query().Get("code"))
|
||||
if err == nil {
|
||||
var user oneOSCurrentUser
|
||||
user, err = a.client.CurrentUser(r.Context(), credential.AccessToken, credential.ClientID)
|
||||
if err == nil {
|
||||
err = a.stage(w, credential, user.Profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
target := "/auth/oneos/complete"
|
||||
if err != nil {
|
||||
a.cookie(w, "", -1)
|
||||
target += "?error=ONEOS_LOGIN_FAILED"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
case "/api/v2/auth/oneos/pending":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
item, err := a.pending(r, false)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
|
||||
return true
|
||||
}
|
||||
p := item.profile
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"identity": map[string]string{"username": p.Username, "displayName": firstNonEmpty(p.Nickname, p.Username), "tenantId": p.TenantID, "departmentName": p.DeptName}, "csrfToken": item.csrf, "expiresAt": item.expires})
|
||||
case "/api/v2/auth/oneos/confirm", "/api/v2/auth/oneos/cancel":
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin || a.origin == "" {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
var input struct {
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
}
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return true
|
||||
}
|
||||
item, err := a.pending(r, false)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(item.csrf), []byte(input.CSRFToken)) != 1 {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_HANDOFF_REJECTED")
|
||||
return true
|
||||
}
|
||||
item, err = a.pending(r, true)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
|
||||
return true
|
||||
}
|
||||
a.cookie(w, "", -1)
|
||||
if strings.HasSuffix(r.URL.Path, "/cancel") {
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"cancelled": true})
|
||||
return true
|
||||
}
|
||||
response, err := a.login(r, item.credential)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
case "/api/v2/auth/oneos/captcha":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if !a.passwordEnabled {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
captcha, err := a.client.FetchCaptcha(r.Context())
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), captcha)
|
||||
case "/api/v2/auth/oneos/login":
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin || a.origin == "" {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
if !a.passwordEnabled {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
if !a.scopeEnabled && !a.devIntegration {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
var input oneOSPasswordLogin
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return true
|
||||
}
|
||||
credential, err := a.client.AuthenticatePassword(r.Context(), input)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_LOGIN_FAILED")
|
||||
return true
|
||||
}
|
||||
response, err := a.login(r, credential)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
default:
|
||||
oneOSAuthError(w, r, 404, "NOT_FOUND")
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (a *oneOSProfileAuth) validProfile(p oneOSProfile) bool {
|
||||
return p.Issuer == a.issuer && p.TenantID == a.tenant && p.UserID != "" && len(p.UserID) <= 96
|
||||
}
|
||||
func (a *oneOSProfileAuth) stage(w http.ResponseWriter, c oneOSCredential, p oneOSProfile) error {
|
||||
if !a.localOnly || !a.validProfile(p) || !c.ExpiresAt.After(time.Now()) {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
token, hash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
csrf, _, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiry := time.Now().Add(2 * time.Minute)
|
||||
if c.ExpiresAt.Before(expiry) {
|
||||
expiry = c.ExpiresAt
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.pendingLogins == nil {
|
||||
a.pendingLogins = map[[32]byte]oneOSPendingLogin{}
|
||||
}
|
||||
for key, item := range a.pendingLogins {
|
||||
if !item.expires.After(time.Now()) {
|
||||
delete(a.pendingLogins, key)
|
||||
}
|
||||
}
|
||||
if len(a.pendingLogins) >= 1000 {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
a.pendingLogins[hash] = oneOSPendingLogin{credential: c, profile: p, csrf: csrf, expires: expiry}
|
||||
a.cookie(w, token, 120)
|
||||
return nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) pending(r *http.Request, consume bool) (oneOSPendingLogin, error) {
|
||||
c, err := r.Cookie(oneOSHandoffCookie)
|
||||
if err != nil || len(c.Value) != 43 {
|
||||
return oneOSPendingLogin{}, errOneOSAuthority
|
||||
}
|
||||
hash := sha256.Sum256([]byte(c.Value))
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
item, ok := a.pendingLogins[hash]
|
||||
if !ok {
|
||||
return item, errOneOSAuthority
|
||||
}
|
||||
if consume || !item.expires.After(time.Now()) {
|
||||
delete(a.pendingLogins, hash)
|
||||
}
|
||||
if !item.expires.After(time.Now()) {
|
||||
return item, errOneOSAuthority
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) bind(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
|
||||
u, id, err := a.bindAttempt(ctx, p)
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if err != nil && errors.As(err, &mysqlErr) && (mysqlErr.Number == 1062 || mysqlErr.Number == 1213 || mysqlErr.Number == 1205) {
|
||||
return a.bindAttempt(ctx, p)
|
||||
}
|
||||
return u, id, err
|
||||
}
|
||||
func (a *oneOSProfileAuth) bindAttempt(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
|
||||
if !a.validProfile(p) {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
if a.store == nil {
|
||||
if !a.localOnly {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
key := string(encoded)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.users == nil {
|
||||
a.users = map[string]authUser{}
|
||||
}
|
||||
if u, ok := a.users[key]; ok {
|
||||
if u.Status != "enabled" {
|
||||
return u, 0, errOneOSAuthority
|
||||
}
|
||||
return u, u.ID, nil
|
||||
}
|
||||
if len(a.users) >= 10000 {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
hash := sha256.Sum256(encoded)
|
||||
u := authUser{ID: uint64(len(a.users) + 1), Username: "oneos-" + hex.EncodeToString(hash[:24]), DisplayName: firstNonEmpty(p.Nickname, p.Username, p.UserID), Status: "enabled", AuthProvider: "oneos-profile"}
|
||||
a.users[key] = u
|
||||
return u, u.ID, nil
|
||||
}
|
||||
tx, err := a.store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return authUser{}, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var u authUser
|
||||
var binding uint64
|
||||
err = tx.QueryRowContext(ctx, `SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider FROM platform_external_identity i JOIN platform_user u ON u.id=i.user_id WHERE i.issuer=? AND i.tenant_id=? AND i.external_user_id=? FOR UPDATE`, p.Issuer, p.TenantID, p.UserID).Scan(&binding, &u.ID, &u.Username, &u.DisplayName, &u.Status, &u.AuthProvider)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
hash := sha256.Sum256(encoded)
|
||||
u.Username = "oneos-" + hex.EncodeToString(hash[:24])
|
||||
u.DisplayName = truncateUTF8(firstNonEmpty(p.Nickname, p.Username, p.UserID), 96)
|
||||
u.Status = "enabled"
|
||||
u.AuthProvider = "oneos-profile"
|
||||
result, e := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,'','customer','enabled',?,'oneos-profile','oneos-profile','oneos-profile')`, u.Username, u.DisplayName, p.TenantID)
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
id, e := result.LastInsertId()
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
u.ID = uint64(id)
|
||||
result, e = tx.ExecContext(ctx, `INSERT INTO platform_external_identity(issuer,tenant_id,external_user_id,user_id) VALUES(?,?,?,?)`, p.Issuer, p.TenantID, p.UserID, u.ID)
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
id, e = result.LastInsertId()
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
binding = uint64(id)
|
||||
} else if err != nil {
|
||||
return u, 0, err
|
||||
}
|
||||
if u.Status != "enabled" || u.AuthProvider != "oneos-profile" {
|
||||
return u, 0, errOneOSAuthority
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return u, 0, err
|
||||
}
|
||||
return u, binding, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) resolve(ctx context.Context, token, clientID string) (platform.Principal, oneOSCurrentUser, error) {
|
||||
current, err := a.client.CurrentUser(ctx, token, clientID)
|
||||
if err != nil || !a.validProfile(current.Profile) {
|
||||
return platform.Principal{}, current, errOneOSAuthority
|
||||
}
|
||||
if a.vehicleQueryScope {
|
||||
return a.resolveVehicleQueryScope(ctx, token, clientID, current)
|
||||
}
|
||||
if oneOSHasAdminPermission(current.Permissions) {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := oneOSAdminPrincipal(u, current.Profile, current.Permissions)
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
// Full authority does not make mock storage into real business data.
|
||||
if a.devIntegration && !a.devDataProxy {
|
||||
p.AuthorizationStatus = "asset-catalog"
|
||||
}
|
||||
if a.devDataProxy {
|
||||
p.ReadOnly = true
|
||||
p.DataSource = "ecs-readonly"
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
if a.devIntegration && !a.scopeEnabled {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, Role: "customer", UserType: "customer", AuthProvider: "oneos", AuthorizationStatus: "pending", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
grants := oneOSPermissions(current.Permissions)
|
||||
if grants["vehicle:platform:access"] && grants["vehiclePlatform:vehicles:view"] && grants["vehiclePlatform:vehicle:read"] {
|
||||
p.AuthorizationStatus = "asset-catalog"
|
||||
p.PermissionKeys = append([]string(nil), current.Permissions...)
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
p.MenuKeys = append(p.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
scope, err := a.client.VehicleScope(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
// Validate authorization before persisting an identity.
|
||||
if _, err = oneOSScopedPrincipal(authUser{}, current.Profile, current.Permissions, scope, time.Now()); err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p, err := oneOSScopedPrincipal(u, current.Profile, current.Permissions, scope, time.Now())
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
return p, current, err
|
||||
}
|
||||
func (a *oneOSProfileAuth) login(r *http.Request, c oneOSCredential) (oneOSLoginResponse, error) {
|
||||
if c.AccessToken == "" || c.ClientID == "" || !c.ExpiresAt.After(time.Now()) {
|
||||
return oneOSLoginResponse{}, errOneOSAuthority
|
||||
}
|
||||
if a.vehicleQueryScope {
|
||||
current, err := a.client.CurrentUser(r.Context(), c.AccessToken, c.ClientID)
|
||||
if err != nil || !a.validProfile(current.Profile) {
|
||||
return oneOSLoginResponse{}, errOneOSAuthority
|
||||
}
|
||||
u, _, err := a.bind(r.Context(), current.Profile)
|
||||
if err != nil {
|
||||
return oneOSLoginResponse{}, err
|
||||
}
|
||||
a.invalidateVehicleQueryScope(c.AccessToken, c.ClientID)
|
||||
principal := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "loading", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
if !oneOSPermissions(current.Permissions)["vehicle:platform:access"] {
|
||||
principal.AuthorizationStatus = "pending"
|
||||
}
|
||||
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
|
||||
}
|
||||
principal, _, err := a.resolve(r.Context(), c.AccessToken, c.ClientID)
|
||||
if err != nil {
|
||||
return oneOSLoginResponse{}, err
|
||||
}
|
||||
// Return the exact upstream token. No platform session or replacement token.
|
||||
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) authenticate(r *http.Request, token string) (platform.Principal, bool) {
|
||||
p, _, err := a.resolve(r.Context(), token, r.Header.Get("X-OneOS-Client-ID"))
|
||||
return p, err == nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSSharedTokenRevalidatesAndNeverFallsBack(t *testing.T) {
|
||||
const token = "exact-upstream-token-123456789"
|
||||
var revoked atomic.Bool
|
||||
var count atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
|
||||
t.Errorf("wrong credential headers")
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
count.Add(1)
|
||||
if revoked.Load() {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
var data any
|
||||
switch r.URL.Path {
|
||||
case "/system/user/getInfo":
|
||||
data = map[string]any{"user": map[string]any{"userId": "7", "tenantId": "t", "userName": "same-name", "nickName": "Seven", "status": "0"}, "permissions": []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}}
|
||||
case "/scope":
|
||||
data = oneOSVehicleScope{TenantID: "t", UserID: "7", PermissionVersion: "v1", ExpiresAt: time.Now().Add(30 * time.Second), Vehicles: []oneOSVehicleGrant{{VIN: "VIN7", ValidFrom: time.Now().Add(-time.Hour)}}}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", vehicleScopePath: "/scope", client: server.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, scopeEnabled: true}
|
||||
req := httptest.NewRequest("GET", "/api/vehicles", nil)
|
||||
req.Header.Set("X-OneOS-Client-ID", "web")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := auth.login(req, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.AccessToken != token || response.ClientID != "web" || response.Session.Role != "customer" || !response.Session.CanVIN("VIN7") || response.Session.CanVIN("VIN8") {
|
||||
t.Fatalf("unsafe login: %#v", response)
|
||||
}
|
||||
if _, ok := auth.authenticate(req, token); !ok {
|
||||
t.Fatal("valid token denied")
|
||||
}
|
||||
if count.Load() != 4 {
|
||||
t.Fatal("did not revalidate identity and scope")
|
||||
}
|
||||
forbidden := httptest.NewRequest("GET", "/api/v2/alerts/events", nil)
|
||||
forbidden.Header = req.Header.Clone()
|
||||
forbiddenRec := httptest.NewRecorder()
|
||||
(&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.NotFoundHandler()).ServeHTTP(forbiddenRec, forbidden)
|
||||
if forbiddenRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("valid token permission denial must be 403: %d", forbiddenRec.Code)
|
||||
}
|
||||
// An upstream-denied token must not fall through to a coincidentally equal legacy token.
|
||||
revoked.Store(true)
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
api := &apiAuthenticator{mode: "enforce", profile: auth, tokens: []tokenPrincipal{{hash: hash, principal: response.Session}}}
|
||||
if _, ok := api.authenticate(req); ok {
|
||||
t.Fatal("revoked token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSStableBindingDoesNotMergeNamesOrReenable(t *testing.T) {
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", localOnly: true}
|
||||
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1", Username: "same"}
|
||||
u, _, err := a.bind(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.UserID = "2"
|
||||
v, _, err := a.bind(context.Background(), p)
|
||||
if err != nil || u.ID == v.ID {
|
||||
t.Fatal("merged names")
|
||||
}
|
||||
p.UserID = "1"
|
||||
raw, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
u.Status = "disabled"
|
||||
a.users[string(raw)] = u
|
||||
if _, _, err := a.bind(context.Background(), p); err == nil {
|
||||
t.Fatal("disabled identity reenabled")
|
||||
}
|
||||
}
|
||||
func TestOneOSPendingConsumeOriginAndExpiry(t *testing.T) {
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", origin: "http://127.0.0.1:20400", localOnly: true}
|
||||
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}
|
||||
w := httptest.NewRecorder()
|
||||
if err := a.stage(w, oneOSCredential{AccessToken: "real-upstream-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatal("unsafe cookie")
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v2/auth/oneos/pending", nil)
|
||||
req.AddCookie(cookie)
|
||||
item, err := a.pending(req, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forged := httptest.NewRequest("POST", "/api/v2/auth/oneos/confirm", strings.NewReader(`{"csrfToken":"`+item.csrf+`"}`))
|
||||
forged.AddCookie(cookie)
|
||||
forged.Header.Set("Origin", "https://attacker.invalid")
|
||||
out := httptest.NewRecorder()
|
||||
a.handle(out, forged)
|
||||
if out.Code != 403 {
|
||||
t.Fatal("cross-origin confirmation allowed")
|
||||
}
|
||||
if _, err := a.pending(req, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.pending(req, true); err == nil {
|
||||
t.Fatal("handoff replay")
|
||||
}
|
||||
if strings.Contains(out.Body.String(), "real-upstream-token") {
|
||||
t.Fatal("token leaked in error")
|
||||
}
|
||||
}
|
||||
func TestOneOSPermissionAndScopeFailClosed(t *testing.T) {
|
||||
now := time.Now()
|
||||
p := oneOSProfile{TenantID: "t", UserID: "1"}
|
||||
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
|
||||
wildcard, err := oneOSScopedPrincipal(authUser{}, p, []string{"*:*:*"}, scope, now)
|
||||
if err != nil || wildcard.Role != "admin" || !wildcard.CanVIN("ANY_VEHICLE") || !wildcard.CanMenu("users") {
|
||||
t.Fatal("OneOS super administrator did not receive platform administration")
|
||||
}
|
||||
for _, mutate := range []func(*oneOSVehicleScope){func(s *oneOSVehicleScope) { s.UserID = "2" }, func(s *oneOSVehicleScope) { s.TenantID = "other" }, func(s *oneOSVehicleScope) { s.ExpiresAt = now.Add(61 * time.Second) }, func(s *oneOSVehicleScope) { s.ExpiresAt = now }, func(s *oneOSVehicleScope) { s.PermissionVersion = "" }} {
|
||||
s := scope
|
||||
mutate(&s)
|
||||
if _, err := oneOSScopedPrincipal(authUser{}, p, []string{"vehicle:platform:access"}, s, now); err == nil {
|
||||
t.Fatal("invalid scope accepted")
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/v2/admin/users", "/api/v2/operations/tasks", "/api/v2/exports", "/api/v2/auth/password", "/api/v2/alerts/events"} {
|
||||
if oneOSReadAction(httptest.NewRequest("GET", path, nil)) != "" {
|
||||
t.Fatal("unsupported route authorized", path)
|
||||
}
|
||||
}
|
||||
if oneOSReadAction(httptest.NewRequest("DELETE", "/api/v2/vehicles/123", nil)) != "" {
|
||||
t.Fatal("write authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDatabaseBindingPreservesDisable(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WithArgs("i", "t", "1").WillReturnRows(sqlmock.NewRows([]string{"id", "uid", "username", "name", "status", "provider"}).AddRow(8, 9, "internal", "Name", "disabled", "oneos-profile"))
|
||||
mock.ExpectRollback()
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
|
||||
if _, _, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}); err == nil {
|
||||
t.Fatal("disabled persisted identity allowed")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestOneOSOriginPolicy(t *testing.T) {
|
||||
for _, origin := range []string{"http://example.com", "https://u:p@example.com", "https://example.com/path", "https://example.com?x=1", "https://example.com#x"} {
|
||||
if validOneOSOrigin(origin, false) {
|
||||
t.Fatal(origin)
|
||||
}
|
||||
}
|
||||
if !validOneOSOrigin("https://example.com", false) || !validOneOSOrigin("http://127.0.0.1:20400", true) || validOneOSOrigin("https://example.com", true) {
|
||||
t.Fatal("origin policy failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDatabaseBindingRetriesConcurrentWinnerOnce(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
cols := []string{"id", "uid", "username", "name", "status", "provider"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols))
|
||||
mock.ExpectExec("INSERT INTO platform_user").WillReturnError(&mysql.MySQLError{Number: 1062, Message: "duplicate"})
|
||||
mock.ExpectRollback()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols).AddRow(8, 9, "internal", "Name", "enabled", "oneos-profile"))
|
||||
mock.ExpectCommit()
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
|
||||
u, id, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"})
|
||||
if err != nil || u.ID != 9 || id != 8 {
|
||||
t.Fatalf("did not reuse concurrent winner: %+v %d %v", u, id, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevPendingIdentityIsLiveAndHasNoBusinessAccess(t *testing.T) {
|
||||
const token = "real-oneos-token-for-test"
|
||||
var disabled atomic.Bool
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/system/user/getInfo" {
|
||||
t.Errorf("pending must not call scope %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
calls.Add(1)
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Error("upstream token changed")
|
||||
}
|
||||
status := "0"
|
||||
if disabled.Load() {
|
||||
status = "1"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "nickName": "Administrator", "status": status}, "permissions": []string{}, "roles": []string{"superadmin"}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: server.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, devIntegration: true}
|
||||
request := httptest.NewRequest("GET", "/api/v2/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-OneOS-Client-ID", "web")
|
||||
login, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if login.AccessToken != token || login.Session.AuthorizationStatus != "pending" || login.Session.Role != "customer" || login.Session.UserType != "customer" || len(login.Session.MenuKeys) != 0 || len(login.Session.VehicleVINs) != 0 || len(login.Session.PermissionKeys) != 0 {
|
||||
t.Fatalf("unsafe pending identity: %+v", login.Session)
|
||||
}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending reached business handler") }))
|
||||
for _, item := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{{"GET", "/api/v2/session", 200}, {"GET", "/api/v2/auth/oneos/me", 200}, {"GET", "/api/vehicles", 403}, {"GET", "/api/v2/monitor/workspace", 403}, {"GET", "/api/v2/admin/users", 403}, {"POST", "/api/v2/session", 403}} {
|
||||
req := httptest.NewRequest(item.method, item.path, nil)
|
||||
req.Header = request.Header.Clone()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != item.status {
|
||||
t.Fatalf("%s %s status %d want %d", item.method, item.path, rec.Code, item.status)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 7 {
|
||||
t.Fatal("identity not revalidated on every request", calls.Load())
|
||||
}
|
||||
disabled.Store(true)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, request)
|
||||
if rec.Code != 401 {
|
||||
t.Fatal("disabled upstream user retained pending identity")
|
||||
}
|
||||
disabled.Store(false)
|
||||
auth.devIntegration = false
|
||||
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
|
||||
t.Fatal("fixture/normal mode accepted missing scope")
|
||||
}
|
||||
auth.devIntegration = true
|
||||
auth.scopeEnabled = true
|
||||
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
|
||||
t.Fatal("configured scope failure downgraded to pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevAssetCatalogDoesNotGrantTelemetryOrHistory(t *testing.T) {
|
||||
const token = "catalog-upstream-token"
|
||||
var permitted atomic.Bool
|
||||
permitted.Store(true)
|
||||
var assetCalls atomic.Int32
|
||||
var catalogOffline atomic.Bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("token changed")
|
||||
}
|
||||
var data any
|
||||
switch r.URL.Path {
|
||||
case "/system/user/getInfo":
|
||||
permissions := []string{}
|
||||
if permitted.Load() {
|
||||
permissions = []string{"*:*:*"}
|
||||
}
|
||||
data = map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}
|
||||
case "/asset/vehicle-info/page":
|
||||
assetCalls.Add(1)
|
||||
if catalogOffline.Load() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("pageNum") != "2" || r.URL.Query().Get("pageSize") != "10" || r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Error("catalog query changed", r.URL.RawQuery)
|
||||
}
|
||||
data = map[string]any{"records": []map[string]any{{"vin": "LREALASSET00000001", "plateNumber": "沪A12345", "brand": "真实品牌"}}, "total": 11, "current": 2, "size": 10}
|
||||
default:
|
||||
t.Errorf("unexpected upstream route %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
client := &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: upstream.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", devIntegration: true, localOnly: true}
|
||||
api := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("catalog user reached mock business") }))
|
||||
request := func(path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
api.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
session := request("/api/v2/session")
|
||||
if session.Code != 200 || !strings.Contains(session.Body.String(), `"authorizationStatus":"asset-catalog"`) || !strings.Contains(session.Body.String(), `"vehicleCount":0`) {
|
||||
t.Fatal("wrong catalog session", session.Body.String())
|
||||
}
|
||||
list := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10")
|
||||
if list.Code != 200 || !strings.Contains(list.Body.String(), "LREALASSET00000001") || !strings.Contains(list.Body.String(), `"telemetryAvailable":false`) {
|
||||
t.Fatal("catalog unavailable", list.Code, list.Body.String())
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles", "/api/v2/monitor/workspace", "/api/v2/tracks?vin=LREALASSET00000001", "/api/v2/vehicles/LREALASSET00000001/telemetry/latest"} {
|
||||
if rec := request(path); rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
|
||||
t.Fatal("catalog leaked platform data", path, rec.Code)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"unknown=1", "pageSize=100", "pageNum=0", "pageSize=", "pageSize=10&pageSize=20"} {
|
||||
rec := request("/api/v2/auth/oneos/vehicles?" + query)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_QUERY_INVALID") || strings.Contains(rec.Body.String(), "登录") {
|
||||
t.Fatal("invalid catalog query misclassified", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
catalogOffline.Store(true)
|
||||
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 502 || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_UNAVAILABLE") || strings.Contains(rec.Body.String(), "登录") {
|
||||
t.Fatal("upstream outage misclassified", rec.Code, rec.Body.String())
|
||||
}
|
||||
permitted.Store(false)
|
||||
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 403 {
|
||||
t.Fatal("revoked catalog permission remained")
|
||||
}
|
||||
if assetCalls.Load() != 2 {
|
||||
t.Fatal("asset fetch happened without permission or for noncatalog routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSChildPermissionsUseThreeSegmentMenuContract(t *testing.T) {
|
||||
now := time.Now()
|
||||
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
|
||||
profile := oneOSProfile{TenantID: "t", UserID: "1"}
|
||||
p, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}, scope, now)
|
||||
if err != nil || !p.CanMenu("vehicles") {
|
||||
t.Fatal("valid three-segment permission denied", err)
|
||||
}
|
||||
old, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehicle:platform:vehicles:view", "vehicle:platform:vehicle:read"}, scope, now)
|
||||
if err != nil || old.CanMenu("vehicles") {
|
||||
t.Fatal("legacy invalid child permission treated as configured menu")
|
||||
}
|
||||
for key := range oneOSPermissions([]string{"*:*:*"}) {
|
||||
if strings.Count(key, ":") != 2 {
|
||||
t.Fatal("permission incompatible with OneOS menu contract", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
// oneOSProfile is identity data only. The profile-jump API conveys no roles,
|
||||
// permissions, data scope, or evidence that the upstream session remains active.
|
||||
type oneOSProfile struct {
|
||||
Issuer string `json:"-"`
|
||||
UserID string `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
DeptID string `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
}
|
||||
|
||||
type oneOSProfileClient struct {
|
||||
baseURL string
|
||||
serviceToken string
|
||||
issuer string
|
||||
allowedTenant string
|
||||
requestTimeout time.Duration
|
||||
client *http.Client
|
||||
passwordEnabled bool
|
||||
passwordClientID string
|
||||
passwordPublicKey *rsa.PublicKey
|
||||
loginTicketExchangePath string
|
||||
loginTicketServiceToken string
|
||||
vehicleScopePath string
|
||||
}
|
||||
|
||||
type oneOSProfileEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type oneOSProfileToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
const oneOSProfileMaxResponse = 64 << 10
|
||||
|
||||
var errOneOSProfileRejected = errors.New("OneOS profile request rejected")
|
||||
|
||||
func newOneOSProfileClient(cfg config.Config) (*oneOSProfileClient, error) {
|
||||
if !cfg.OneOSProfileEnabled && !cfg.OneOSAuthEnabled && !cfg.OneOSPasswordEnabled && cfg.OneOSLoginTicketExchangePath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
|
||||
return nil, errors.New("OneOS local-only and dev-integration modes are mutually exclusive")
|
||||
}
|
||||
if cfg.OneOSDevIntegration && !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
|
||||
return nil, errors.New("local OneOS integration permits only the configured dev or production HTTPS origin")
|
||||
}
|
||||
productionOneOS := strings.TrimRight(cfg.OneOSProfileBaseURL, "/") == "https://lnoneos.com/prod-api"
|
||||
base, err := url.Parse(strings.TrimSpace(cfg.OneOSProfileBaseURL))
|
||||
if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (!cfg.OneOSDevIntegration && !productionOneOS && base.Path != "" && base.Path != "/") {
|
||||
return nil, errors.New("ONEOS_PROFILE_BASE_URL must be an HTTPS origin")
|
||||
}
|
||||
loopback := net.ParseIP(base.Hostname()) != nil && net.ParseIP(base.Hostname()).IsLoopback()
|
||||
if cfg.OneOSLocalOnly && !loopback {
|
||||
return nil, errors.New("ONEOS_LOCAL_ONLY requires a literal loopback OneOS origin")
|
||||
}
|
||||
if base.Scheme != "https" && !(cfg.OneOSLocalOnly && loopback && base.Scheme == "http") {
|
||||
return nil, errors.New("ONEOS_PROFILE_BASE_URL must use HTTPS outside explicit local-only mode")
|
||||
}
|
||||
if cfg.OneOSProfileServiceToken != "" && (len(cfg.OneOSProfileServiceToken) < 32 || strings.TrimSpace(cfg.OneOSProfileServiceToken) != cfg.OneOSProfileServiceToken || strings.ContainsAny(cfg.OneOSProfileServiceToken, "\r\n ")) {
|
||||
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
|
||||
}
|
||||
if cfg.OneOSProfileEnabled && len(cfg.OneOSProfileServiceToken) < 32 {
|
||||
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN is required for profile-jump")
|
||||
}
|
||||
if err := validateOneOSConfiguredPath(cfg.OneOSLoginTicketExchangePath); err != nil {
|
||||
return nil, errors.New("ONEOS_LOGIN_TICKET_EXCHANGE_PATH is invalid")
|
||||
}
|
||||
if cfg.OneOSLoginTicketExchangePath != "" && (len(cfg.OneOSLoginTicketServiceToken) < 32 || strings.TrimSpace(cfg.OneOSLoginTicketServiceToken) != cfg.OneOSLoginTicketServiceToken || strings.ContainsAny(cfg.OneOSLoginTicketServiceToken, " \t\r\n")) {
|
||||
return nil, errors.New("ONEOS_LOGIN_TICKET_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
|
||||
}
|
||||
if err := validateOneOSConfiguredPath(cfg.OneOSVehicleScopePath); err != nil {
|
||||
return nil, errors.New("ONEOS_VEHICLE_SCOPE_PATH is invalid")
|
||||
}
|
||||
issuer := strings.TrimSpace(cfg.OneOSProfileIssuer)
|
||||
tenant := strings.TrimSpace(cfg.OneOSProfileAllowedTenant)
|
||||
if issuer == "" || tenant == "" {
|
||||
return nil, errors.New("ONEOS_PROFILE_ISSUER and ONEOS_PROFILE_ALLOWED_TENANT are required")
|
||||
}
|
||||
timeout := cfg.OneOSProfileTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3500 * time.Millisecond
|
||||
}
|
||||
if timeout >= 5*time.Second {
|
||||
return nil, errors.New("ONEOS_PROFILE_TIMEOUT_MS must be less than 5000")
|
||||
}
|
||||
if cfg.OneOSDevIntegration || productionOneOS {
|
||||
base.Path = "/prod-api"
|
||||
} else {
|
||||
base.Path = ""
|
||||
}
|
||||
var passwordPublicKey *rsa.PublicKey
|
||||
if cfg.OneOSPasswordEnabled {
|
||||
if strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
|
||||
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required")
|
||||
}
|
||||
passwordPublicKey, err = parseOneOSRSAPublicKeyForMode(cfg.OneOSPasswordRSAPublicKey, cfg.OneOSDevIntegration || productionOneOS)
|
||||
if err != nil {
|
||||
return nil, errors.New("ONEOS_PASSWORD_RSA_PUBLIC_KEY is invalid")
|
||||
}
|
||||
}
|
||||
if cfg.OneOSLoginTicketExchangePath != "" && strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
|
||||
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required for login-ticket exchange")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.ResponseHeaderTimeout = timeout
|
||||
return &oneOSProfileClient{
|
||||
baseURL: base.String(), serviceToken: cfg.OneOSProfileServiceToken,
|
||||
issuer: issuer, allowedTenant: tenant, requestTimeout: timeout,
|
||||
passwordEnabled: cfg.OneOSPasswordEnabled, passwordClientID: cfg.OneOSPasswordClientID, passwordPublicKey: passwordPublicKey,
|
||||
loginTicketExchangePath: cfg.OneOSLoginTicketExchangePath, loginTicketServiceToken: cfg.OneOSLoginTicketServiceToken, vehicleScopePath: cfg.OneOSVehicleScopePath,
|
||||
client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeCode consumes a code exactly once, then reads its bound profile.
|
||||
// The whole exchange shares one deadline; a failed call is never retried.
|
||||
func (c *oneOSProfileClient) ExchangeCode(ctx context.Context, code string) (oneOSProfile, error) {
|
||||
if c == nil || c.serviceToken == "" {
|
||||
return oneOSProfile{}, errors.New("OneOS profile client is disabled")
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(code)
|
||||
if len(code) != 43 || err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != code {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile code")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(struct {
|
||||
Code string `json:"code"`
|
||||
}{code})
|
||||
var token oneOSProfileToken
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/auth/inner/v1/sso/profile-jump/exchange", body, c.serviceToken, &token); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
if !validOneOSOpaqueToken(token.AccessToken) || token.TokenType != "Bearer" || !token.ExpiresAt.After(time.Now().Add(time.Second)) {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile token response")
|
||||
}
|
||||
var profile oneOSProfile
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/auth/sso/profile-jump/me", nil, token.AccessToken, &profile); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
if strings.TrimSpace(profile.UserID) == "" || strings.TrimSpace(profile.TenantID) == "" || profile.TenantID != c.allowedTenant {
|
||||
return oneOSProfile{}, errors.New("OneOS profile identity or tenant rejected")
|
||||
}
|
||||
profile.Issuer = c.issuer
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func validOneOSOpaqueToken(value string) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return len(value) == 43 && err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) doJSON(ctx context.Context, method, path string, body []byte, bearer string, target any) error {
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("invalid OneOS request")
|
||||
}
|
||||
if bearer != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if method == http.MethodPost {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: transport failure", errOneOSProfileRejected)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: HTTP %d", errOneOSProfileRejected, response.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[json.RawMessage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, target); err != nil {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func testOneOSProfileClient(t *testing.T, server *httptest.Server) *oneOSProfileClient {
|
||||
t.Helper()
|
||||
client, err := newOneOSProfileClient(config.Config{
|
||||
OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL,
|
||||
OneOSProfileServiceToken: strings.Repeat("s", 32),
|
||||
OneOSProfileIssuer: "oneos-dev", OneOSProfileAllowedTenant: "tenant-a",
|
||||
OneOSProfileTimeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.client = server.Client()
|
||||
client.client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return client
|
||||
}
|
||||
|
||||
func TestOneOSProfileExchangeUsesTwoTokensAndMinimalIdentity(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/auth/inner/v1/sso/profile-jump/exchange":
|
||||
calls.Add(1)
|
||||
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
|
||||
t.Errorf("bad exchange request")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != `{"code":"`+code+`"}` {
|
||||
t.Errorf("bad code body")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
|
||||
case "/auth/sso/profile-jump/me":
|
||||
calls.Add(1)
|
||||
if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+profileToken {
|
||||
t.Errorf("bad profile request")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "tenant-a", "username": "jane", "nickname": "Jane", "deptId": "7", "deptName": "East", "permissions": []string{"*"}}})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testOneOSProfileClient(t, server)
|
||||
profile, err := client.ExchangeCode(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 || profile.UserID != "42" || profile.Issuer != "oneos-dev" || profile.TenantID != "tenant-a" {
|
||||
t.Fatalf("unexpected profile: %+v calls=%d", profile, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSProfileExchangeRejectsTenantAndDoesNotRetry(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Path == "/auth/inner/v1/sso/profile-jump/exchange" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "other"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testOneOSProfileClient(t, server)
|
||||
if _, err := client.ExchangeCode(context.Background(), code); err == nil {
|
||||
t.Fatal("cross-tenant identity accepted")
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("unexpected retries: %d", calls.Load())
|
||||
}
|
||||
if _, err := client.ExchangeCode(context.Background(), "invalid"); err == nil || calls.Load() != 2 {
|
||||
t.Fatal("invalid code reached OneOS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSProfileClientRequiresHTTPSAndTenant(t *testing.T) {
|
||||
base := config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant"}
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("HTTP origin accepted")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "https://oneos.example"
|
||||
base.OneOSProfileAllowedTenant = ""
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("missing tenant accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevTransportFixedOriginPrefixWithoutNetwork(t *testing.T) {
|
||||
cfg := config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"}
|
||||
client, err := newOneOSProfileClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.baseURL != "https://dev-oneos.lnh2e.com/prod-api" {
|
||||
t.Fatal("API prefix lost", client.baseURL)
|
||||
}
|
||||
tr, ok := client.client.Transport.(*http.Transport)
|
||||
if !ok || tr.Proxy != nil {
|
||||
t.Fatal("environment proxy allowed")
|
||||
}
|
||||
if client.client.CheckRedirect(&http.Request{}, nil) != http.ErrUseLastResponse {
|
||||
t.Fatal("redirect allowed")
|
||||
}
|
||||
for _, value := range []string{"https://dev-oneos.lnh2e.com", "https://dev-oneos.lnh2e.com/prod-api/../evil", "https://dev-oneos.lnh2e.com/prod-api?x=1", "https://dev-oneos.lnh2e.com.evil.invalid/prod-api", "https://dev-oneos.lnh2e.com:444/prod-api", "http://127.0.0.1/prod-api"} {
|
||||
c := cfg
|
||||
c.OneOSProfileBaseURL = value
|
||||
if _, err := newOneOSProfileClient(c); err == nil {
|
||||
t.Fatal("invalid dev origin", value)
|
||||
}
|
||||
}
|
||||
cfg.OneOSLocalOnly = true
|
||||
if _, err := newOneOSProfileClient(cfg); err == nil {
|
||||
t.Fatal("fixture/dev modes conflict accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type oneOSDevURLCaptureTransport struct{ t *testing.T }
|
||||
|
||||
func (c oneOSDevURLCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if got := r.URL.String(); got != "https://dev-oneos.lnh2e.com/prod-api/auth/code" {
|
||||
c.t.Fatal("wrong fixed request URL", got)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"data":{"captchaEnabled":false}}`)), Request: r}, nil
|
||||
}
|
||||
func TestOneOSDevRequestKeepsVerifiedAPIPrefixWithoutNetwork(t *testing.T) {
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api/", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.passwordEnabled = true
|
||||
client.client.Transport = oneOSDevURLCaptureTransport{t: t}
|
||||
if _, err := client.FetchCaptcha(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionOneOSGatewayPrefix(t *testing.T) {
|
||||
cfg := config.Config{OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://lnoneos.com/prod-api", OneOSProfileIssuer: "https://lnoneos.com", OneOSProfileAllowedTenant: "000000"}
|
||||
client, err := newOneOSProfileClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.baseURL != "https://lnoneos.com/prod-api" {
|
||||
t.Fatal(client.baseURL)
|
||||
}
|
||||
cfg.OneOSProfileBaseURL = "https://other.example/prod-api"
|
||||
if _, err := newOneOSProfileClient(cfg); err == nil {
|
||||
t.Fatal("unexpected gateway prefix accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const oneOSQueryScopeTTL = 2 * time.Minute
|
||||
const oneOSQueryScopePageSize int64 = 200
|
||||
|
||||
type oneOSQueryScopeEntry struct {
|
||||
ready chan struct{}
|
||||
vehicles []oneOSAssetVehicle
|
||||
expires time.Time
|
||||
err error
|
||||
}
|
||||
|
||||
// A new login must establish a fresh snapshot even if OneOS reuses its token.
|
||||
func (a *oneOSProfileAuth) invalidateVehicleQueryScope(token, clientID string) {
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.scopeMu.Lock()
|
||||
delete(a.queryScopes, key)
|
||||
a.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
// Each snapshot uses the caller's own upstream credentials. Browser filters,
|
||||
// service credentials and cached snapshots from other sessions cannot widen it.
|
||||
func (a *oneOSProfileAuth) vehicleQueryVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
rows, err := a.vehicleQuerySnapshot(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
seen[row.VIN] = true
|
||||
}
|
||||
vins := make([]string, 0, len(seen))
|
||||
for vin := range seen {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) vehicleQuerySnapshot(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.scopeMu.Lock()
|
||||
if a.queryScopes == nil {
|
||||
a.queryScopes = map[[32]byte]*oneOSQueryScopeEntry{}
|
||||
}
|
||||
for k, e := range a.queryScopes {
|
||||
select {
|
||||
case <-e.ready:
|
||||
if !e.expires.After(time.Now()) {
|
||||
delete(a.queryScopes, k)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
if e, ok := a.queryScopes[key]; ok {
|
||||
a.scopeMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-e.ready:
|
||||
return append([]oneOSAssetVehicle{}, e.vehicles...), e.err
|
||||
}
|
||||
}
|
||||
if len(a.queryScopes) >= 1000 {
|
||||
a.scopeMu.Unlock()
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
e := &oneOSQueryScopeEntry{ready: make(chan struct{})}
|
||||
a.queryScopes[key] = e
|
||||
a.scopeMu.Unlock()
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
vehicles, err := a.client.queryOperatingVehicles(fetchCtx, token, clientID)
|
||||
a.scopeMu.Lock()
|
||||
e.vehicles, e.err, e.expires = vehicles, err, time.Now().Add(oneOSQueryScopeTTL)
|
||||
// An older in-flight request must not remove a newer login's snapshot.
|
||||
if err != nil && a.queryScopes[key] == e {
|
||||
delete(a.queryScopes, key)
|
||||
}
|
||||
close(e.ready)
|
||||
a.scopeMu.Unlock()
|
||||
return append([]oneOSAssetVehicle{}, vehicles...), err
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryVisibleVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := c.queryOperatingVehicleVINs(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, vin := range rows {
|
||||
seen[vin] = true
|
||||
}
|
||||
vins := make([]string, 0, len(seen))
|
||||
for vin := range seen {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryOperatingVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
rows, err := c.queryOperatingVehicles(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vins := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
vins = append(vins, row.VIN)
|
||||
}
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryOperatingVehicles(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
fetch := func(pageNum int64) (oneOSAssetPage, error) {
|
||||
return c.vehicleAssetPage(ctx, token, clientID, url.Values{"assetCategory": {oneOSVehicleAssetCategory}, "pageNum": {strconv.FormatInt(pageNum, 10)}, "pageSize": {strconv.FormatInt(oneOSQueryScopePageSize, 10)}}, oneOSQueryScopePageSize)
|
||||
}
|
||||
first, err := fetch(1)
|
||||
if err != nil || first.Total > 100000 {
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
total := first.Total
|
||||
rowIDs := map[string]bool{}
|
||||
vehicles := []oneOSAssetVehicle{}
|
||||
consume := func(pageNum int64, page oneOSAssetPage) error {
|
||||
want := total - (pageNum-1)*oneOSQueryScopePageSize
|
||||
if want > oneOSQueryScopePageSize {
|
||||
want = oneOSQueryScopePageSize
|
||||
}
|
||||
if page.Total != total || want < 0 || int64(len(page.Records)) != want {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
for _, row := range page.Records {
|
||||
if row.ID == "" || rowIDs[row.ID] {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
rowIDs[row.ID] = true
|
||||
vin := strings.ToUpper(strings.TrimSpace(row.VIN))
|
||||
if validScopeVIN(vin) {
|
||||
row.VIN = vin
|
||||
vehicles = append(vehicles, row)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = consume(1, first); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fetch at most four pages concurrently; consume in page order so duplicate
|
||||
// and completeness checks remain identical to sequential enumeration.
|
||||
for start := int64(2); start <= (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize; start += 4 {
|
||||
count := (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize - start + 1
|
||||
if count > 4 {
|
||||
count = 4
|
||||
}
|
||||
pages := make([]oneOSAssetPage, int(count))
|
||||
errs := make([]error, int(count))
|
||||
var wg sync.WaitGroup
|
||||
for i := range pages {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
pages[i], errs[i] = fetch(start + int64(i))
|
||||
if errs[i] != nil {
|
||||
cancel()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, page := range pages {
|
||||
if errs[i] != nil {
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
if err = consume(start+int64(i), page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vehicles, nil
|
||||
}
|
||||
|
||||
func validScopeVIN(vin string) bool {
|
||||
if len(vin) != 17 {
|
||||
return false
|
||||
}
|
||||
for _, r := range vin {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) resolveVehicleQueryScope(ctx context.Context, token, clientID string, current oneOSCurrentUser) (platform.Principal, oneOSCurrentUser, error) {
|
||||
grants := oneOSPermissions(current.Permissions)
|
||||
if !grants["vehicle:platform:access"] {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
return platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "pending", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}, current, nil
|
||||
}
|
||||
vins, err := a.vehicleQueryVINs(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := platform.Principal{PermissionKeys: append([]string(nil), current.Permissions...), SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", MenuKeys: []string{}}
|
||||
if oneOSHasAdminPermission(current.Permissions) {
|
||||
p = oneOSAdminPrincipal(u, current.Profile, current.Permissions)
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
} else {
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
p.MenuKeys = append(p.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.VehicleVINs, p.VehicleCount = vins, len(vins)
|
||||
p.VehicleScopeSource = "oneos-vehicle-query"
|
||||
p.AuthorizationStatus = "ready"
|
||||
if a.localBackend {
|
||||
p.DataSource = "production-local"
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVehicleQueryScopeFullPaginationAndCredentialIsolation(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Error("vehicle scope queried a non-operating category")
|
||||
}
|
||||
if r.URL.Path != "/asset/vehicle-info/page" || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("wrong upstream request")
|
||||
}
|
||||
total := int64(0)
|
||||
records := []oneOSAssetVehicle{}
|
||||
if r.URL.Query().Get("assetCategory") == "allOperating" {
|
||||
total = 201
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("pageNum"))
|
||||
for i := (page - 1) * 200; i < page*200 && i < 201; i++ {
|
||||
records = append(records, oneOSAssetVehicle{ID: strconv.Itoa(i + 1), VIN: fmt.Sprintf("LNX%014d", i), CustomerName: "客户甲", BusinessDepartment: "业务一部", BusinessResponsiblePerson: "负责人甲"})
|
||||
}
|
||||
if r.Header.Get("Authorization") == "Bearer second-user-token" {
|
||||
total = 0
|
||||
records = []oneOSAssetVehicle{}
|
||||
}
|
||||
}
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
first, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || len(first) != 201 {
|
||||
t.Fatalf("incomplete scope: %d %v", len(first), err)
|
||||
}
|
||||
n := calls.Load()
|
||||
snapshot, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || len(snapshot) != 201 || snapshot[200].CustomerName != "客户甲" || snapshot[200].BusinessDepartment != "业务一部" || snapshot[200].BusinessResponsiblePerson != "负责人甲" || calls.Load() != n {
|
||||
t.Fatalf("metadata snapshot lost paginated assignment fields or repeated requests: %v", err)
|
||||
}
|
||||
snapshot[0].CustomerName = "tampered"
|
||||
again, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || again[0].CustomerName != "客户甲" {
|
||||
t.Fatal("metadata cache is mutable by caller")
|
||||
}
|
||||
first[0] = "tampered"
|
||||
cached, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || cached[0] == "tampered" || calls.Load() != n {
|
||||
t.Fatal("unsafe cache")
|
||||
}
|
||||
second, err := a.vehicleQueryVINs(context.Background(), "second-user-token", "web")
|
||||
if err != nil || len(second) != 0 {
|
||||
t.Fatal("another user's scope leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeFailsClosedOnIncompleteOrFailedPage(t *testing.T) {
|
||||
for _, scenario := range []string{"truncated", "changed-total", "repeated-row", "upstream-failed"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if scenario == "upstream-failed" {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
records := []oneOSAssetVehicle{}
|
||||
count := 200
|
||||
total := int64(201)
|
||||
if page == 2 {
|
||||
count = 1
|
||||
}
|
||||
if scenario == "truncated" {
|
||||
count = 0
|
||||
}
|
||||
if page == 2 && scenario == "changed-total" {
|
||||
total = 202
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
id := fmt.Sprint((page-1)*200 + int64(i))
|
||||
if scenario == "repeated-row" {
|
||||
id = "same"
|
||||
}
|
||||
records = append(records, oneOSAssetVehicle{ID: id, VIN: fmt.Sprintf("LNX%014d", i)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}
|
||||
if _, err := c.queryVisibleVehicleVINs(context.Background(), "user-token-123456", "web"); err == nil {
|
||||
t.Fatal("partial scope accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeDoesNotReuseExpiredSnapshotOnFailure(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail.Load() {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range a.queryScopes {
|
||||
entry.expires = time.Now().Add(-time.Second)
|
||||
}
|
||||
fail.Store(true)
|
||||
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err == nil {
|
||||
t.Fatal("stale authorization reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeRechecksRolesAndNeverGrantsUnlistedVINs(t *testing.T) {
|
||||
var admin atomic.Bool
|
||||
admin.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
if admin.Load() {
|
||||
permissions = []string{"*:*:*"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
records := []oneOSAssetVehicle{}
|
||||
if r.URL.Query().Get("assetCategory") == "allOperating" {
|
||||
records = []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: int64(len(records)), Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, scopeEnabled: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
p, _, err := a.resolve(context.Background(), "original-user-token", "web")
|
||||
if err != nil || p.Role != "admin" || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("L0000000000000000") {
|
||||
t.Fatalf("bad admin scope: %v %+v", err, p)
|
||||
}
|
||||
admin.Store(false)
|
||||
p, _, err = a.resolve(context.Background(), "original-user-token", "web")
|
||||
if err != nil || p.Role != "customer" || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
|
||||
t.Fatalf("stale administrator grant: %v %+v", err, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeLoginRefreshAndFixedExpiry(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
var vin atomic.Value
|
||||
vin.Store("LNXNEGRR3SR321379")
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
|
||||
return
|
||||
}
|
||||
calls.Add(1)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: vin.Load().(string)}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
request := httptest.NewRequest("POST", "/login", nil)
|
||||
before := time.Now()
|
||||
login, err := a.login(request, credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" || login.Session.CanVIN("LNXNEGRR3SR321379") || calls.Load() != 0 {
|
||||
t.Fatalf("login must return identity without querying vehicles: %v", err)
|
||||
}
|
||||
if _, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := sha256.Sum256([]byte("web\x00" + credential.AccessToken))
|
||||
entry := a.queryScopes[key]
|
||||
if entry.expires.Before(before.Add(2*time.Minute)) || entry.expires.After(time.Now().Add(2*time.Minute)) {
|
||||
t.Fatal("scope TTL is not two minutes")
|
||||
}
|
||||
// A hit must retain the existing deadline instead of extending it.
|
||||
entry.expires = time.Now().Add(time.Minute)
|
||||
deadline := entry.expires
|
||||
vin.Store("LNXNEGRR3SR321380")
|
||||
p, _, err := a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 1 || !entry.expires.Equal(deadline) {
|
||||
t.Fatal("cache hit changed scope or extended deadline")
|
||||
}
|
||||
login, err = a.login(request, credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" || calls.Load() != 1 {
|
||||
t.Fatal("repeat login must defer vehicle lookup")
|
||||
}
|
||||
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || p.CanVIN("LNXNEGRR3SR321379") || !p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 2 {
|
||||
t.Fatal("repeat login reused old scope")
|
||||
}
|
||||
a.queryScopes[key].expires = time.Now().Add(-time.Second)
|
||||
vin.Store("LNXNEGRR3SR321381")
|
||||
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || p.CanVIN("LNXNEGRR3SR321380") || !p.CanVIN("LNXNEGRR3SR321381") || calls.Load() != 3 {
|
||||
t.Fatal("expired scope was not refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeOldFailureCannotEvictNewSnapshot(t *testing.T) {
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
close(started)
|
||||
<-release
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: 5 * time.Second, passwordClientID: "web"}}
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); done <- err }()
|
||||
<-started
|
||||
a.invalidateVehicleQueryScope("original-user-token", "web")
|
||||
_, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web")
|
||||
close(release)
|
||||
oldErr := <-done
|
||||
if err != nil || oldErr == nil {
|
||||
t.Fatalf("unexpected query results: new=%v old=%v", err, oldErr)
|
||||
}
|
||||
if _, err = a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); err != nil || calls.Load() != 2 {
|
||||
t.Fatal("old failure evicted new snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeBoundedParallelPagination(t *testing.T) {
|
||||
var active, peak, calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
n := active.Add(1)
|
||||
defer active.Add(-1)
|
||||
for old := peak.Load(); n > old; old = peak.Load() {
|
||||
if peak.CompareAndSwap(old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Model network latency across the 6 pages used by a 1,026-vehicle scope.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
records := []oneOSAssetVehicle{}
|
||||
for i := (page - 1) * 200; i < page*200 && i < 1026; i++ {
|
||||
records = append(records, oneOSAssetVehicle{ID: strconv.FormatInt(i+1, 10), VIN: fmt.Sprintf("LNX%014d", i)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: 1026, Current: page, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
c := &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}
|
||||
started := time.Now()
|
||||
vins, err := c.queryVisibleVehicleVINs(context.Background(), "original-user-token", "web")
|
||||
elapsed := time.Since(started)
|
||||
if err != nil || len(vins) != 1026 || calls.Load() != 6 {
|
||||
t.Fatalf("incomplete scope: count=%d calls=%d err=%v", len(vins), calls.Load(), err)
|
||||
}
|
||||
if peak.Load() < 2 || peak.Load() > 4 {
|
||||
t.Fatalf("unexpected concurrency: %d", peak.Load())
|
||||
}
|
||||
t.Logf("6 pages with 50ms simulated latency: %s, sequential minimum 0.30s, peak concurrency %d", elapsed, peak.Load())
|
||||
}
|
||||
|
||||
func TestVehicleScopeEndpointSeparatesIdentityAndRetries(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
fail.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
|
||||
return
|
||||
}
|
||||
if fail.Load() {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
login, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" {
|
||||
t.Fatal("unavailable vehicle service blocked identity login")
|
||||
}
|
||||
auth := &apiAuthenticator{mode: "enforce", profile: profile}
|
||||
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("scope request reached business handler") }))
|
||||
request := func(token, client string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/v2/auth/vehicle-scope", nil)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if client != "" {
|
||||
r.Header.Set("X-OneOS-Client-ID", client)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
if w := request("", ""); w.Code != 401 {
|
||||
t.Fatalf("anonymous scope: %d", w.Code)
|
||||
}
|
||||
if w := request(credential.AccessToken, credential.ClientID); w.Code != 503 {
|
||||
t.Fatalf("scope outage should be retryable: %d", w.Code)
|
||||
}
|
||||
fail.Store(false)
|
||||
w := request(credential.AccessToken, credential.ClientID)
|
||||
var body struct {
|
||||
Data struct {
|
||||
AuthorizationStatus string `json:"authorizationStatus"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != 200 || body.Data.AuthorizationStatus != "ready" || body.Data.VehicleCount != 1 {
|
||||
t.Fatalf("scope retry failed: status=%d err=%v", w.Code, err)
|
||||
}
|
||||
if w := request(credential.AccessToken, ""); w.Code != 401 {
|
||||
t.Fatal("OneOS token crossed into local authentication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryOneOSLoginWithoutEntryPermissionIsPending(t *testing.T) {
|
||||
var granted atomic.Bool
|
||||
var vehicleCalls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
permissions := []string{}
|
||||
if granted.Load() {
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "2", "tenantId": "t", "userName": "ordinary", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
vehicleCalls.Add(1)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "ordinary-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
result, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
|
||||
if err != nil || result.Session.AuthorizationStatus != "pending" || result.Session.CanVIN("LNXNEGRR3SR321379") || result.Session.CanMenu("vehicles") || vehicleCalls.Load() != 0 {
|
||||
t.Fatal("ordinary identity should log in without receiving business access")
|
||||
}
|
||||
auth := &apiAuthenticator{mode: "enforce", profile: profile}
|
||||
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("pending user reached business handler") }))
|
||||
for _, path := range []string{"/api/v2/session", "/api/v2/auth/vehicle-scope", "/api/v2/auth/oneos/vehicles", "/api/v2/vehicles"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+credential.AccessToken)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
want := 403
|
||||
if path == "/api/v2/session" || path == "/api/v2/auth/vehicle-scope" {
|
||||
want = 200
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s status=%d want=%d", path, w.Code, want)
|
||||
}
|
||||
}
|
||||
if vehicleCalls.Load() != 0 {
|
||||
t.Fatal("pending user queried vehicle catalog")
|
||||
}
|
||||
granted.Store(true)
|
||||
p, _, err := profile.resolve(context.Background(), credential.AccessToken, "web")
|
||||
if err != nil || p.AuthorizationStatus != "ready" || p.Role != "customer" || !p.CanMenu("vehicles") || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
|
||||
t.Fatal("newly granted ordinary user could not refresh scoped access")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOperationComplianceOnlyPermissionAndCallerScopedAssignments(t *testing.T) {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:operationCompliance:view"}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
second := strings.Contains(r.Header.Get("Authorization"), "second")
|
||||
user, vin, customer := "21", "LB9A32A24R0LS1426", "客户甲"
|
||||
if second {
|
||||
user, vin, customer = "22", "LNXNEGRR7SR318212", "客户乙"
|
||||
}
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": user, "tenantId": "000000", "userName": "operation-only", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/asset/vehicle-info/page" || r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Errorf("unexpected upstream request: %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: user, VIN: vin, CustomerName: customer, BusinessDepartment: "业务一部", BusinessResponsiblePerson: "张业务"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
|
||||
request := func(token, path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, tc := range []struct{ token, vin, customer, forbidden string }{
|
||||
{"first-operation-token", "LB9A32A24R0LS1426", "客户甲", "客户乙"},
|
||||
{"second-operation-token", "LNXNEGRR7SR318212", "客户乙", "客户甲"},
|
||||
} {
|
||||
w := request(tc.token, "/api/v2/operation-compliance/options")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.customer) || strings.Contains(w.Body.String(), tc.forbidden) {
|
||||
t.Fatalf("scoped options failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(tc.token, "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource=hydrogen&scopeVins=FORGED&vins=FORGED&protocol=GB32960")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || strings.Contains(w.Body.String(), tc.forbidden) || strings.Contains(w.Body.String(), "FORGED") {
|
||||
t.Fatalf("roster scope failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, source := range []string{"gps", "odometer", "hydrogen"} {
|
||||
w = request(tc.token, "/api/v2/operation-compliance/detail?vin="+tc.vin+"&dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource="+source+"&scopeVins=FORGED")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || !strings.Contains(w.Body.String(), `"mileageSource":"`+source+`"`) || strings.Contains(w.Body.String(), tc.forbidden) {
|
||||
t.Fatalf("daily scope failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if denied := request(tc.token, "/api/v2/operation-compliance/detail?vin=LNX00000000000002&dateFrom=2026-09-01&dateTo=2026-09-03"); denied.Code != 403 {
|
||||
t.Fatalf("daily VIN escape: %d", denied.Code)
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles", "/api/v2/statistics/vehicle-options", "/api/v2/customer-mileage"} {
|
||||
if denied := request(tc.token, path); denied.Code != 403 {
|
||||
t.Fatalf("new menu escalated access to %s: %d", path, denied.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access"}
|
||||
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
|
||||
if w := request("first-operation-token", path); w.Code != 403 {
|
||||
t.Fatalf("revocation not enforced: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceMenuMappingAndNativeConfiguration(t *testing.T) {
|
||||
if oneOSMenuPermission("operation-compliance") != "vehiclePlatform:operationCompliance:view" || !customerMenuSet["operation-compliance"] || !adminMenuSet["operation-compliance"] {
|
||||
t.Fatal("menu mapping missing")
|
||||
}
|
||||
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
if requiredMenu(r) != "operation-compliance" || requiredRole(r) != "viewer" || oneOSReadAction(r) != "vehiclePlatform:operationCompliance:view" {
|
||||
t.Fatalf("permission dependency mismatch: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
)
|
||||
|
||||
func NewServer(cfg config.Config) http.Handler {
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_ISOLATION_REQUIRED", err.Error(), "", requestTraceID(r))
|
||||
})
|
||||
}
|
||||
dataMode := strings.ToLower(strings.TrimSpace(cfg.DataMode))
|
||||
if dataMode == "" {
|
||||
dataMode = "mock"
|
||||
@@ -124,10 +129,22 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
// Reverse geocoding consumes the server-side AMap credential, so it must
|
||||
// stay behind the same authentication boundary as the platform API.
|
||||
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
var devData *oneOSDevDataProxy
|
||||
if cfg.OneOSDevDataProxyURL != "" {
|
||||
devData = newOneOSDevDataProxy(cfg)
|
||||
api = devData
|
||||
}
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB))
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
if devData != nil {
|
||||
handler = devData.withMapProxy(handler)
|
||||
}
|
||||
handler = withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
if cfg.OneOSLocalOnly || cfg.OneOSDevIntegration {
|
||||
handler = withOneOSLocalIsolationHeaders(handler, devData != nil || cfg.OneOSLocalBackend)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
|
||||
@@ -610,7 +627,9 @@ func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
||||
}
|
||||
buffered.writeTo(w)
|
||||
case <-ctx.Done():
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", requestTraceID(r))
|
||||
traceID := requestTraceID(r)
|
||||
log.Printf("request timed out trace_id=%q method=%s path=%s timeout_ms=%d", traceID, r.Method, r.URL.Path, timeout.Milliseconds())
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", traceID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,26 @@ type Config struct {
|
||||
OneOSDefaultMenus []string
|
||||
OneOSRequestTimeout time.Duration
|
||||
OneOSSessionTTL time.Duration
|
||||
OneOSProfileEnabled bool
|
||||
OneOSAuthEnabled bool
|
||||
OneOSProfileBaseURL string
|
||||
OneOSProfileServiceToken string
|
||||
OneOSProfileIssuer string
|
||||
OneOSProfileAllowedTenant string
|
||||
OneOSProfileTimeout time.Duration
|
||||
OneOSPublicOrigin string
|
||||
OneOSPasswordEnabled bool
|
||||
OneOSPasswordClientID string
|
||||
OneOSPasswordRSAPublicKey string
|
||||
OneOSLoginTicketExchangePath string
|
||||
OneOSLoginTicketServiceToken string
|
||||
OneOSDevIntegration bool
|
||||
OneOSLocalBackend bool
|
||||
OneOSVehicleQueryScope bool
|
||||
OneOSDevDataProxyURL string
|
||||
OneOSDevDataProxyToken string
|
||||
OneOSLocalOnly bool
|
||||
OneOSVehicleScopePath string
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
@@ -93,6 +113,26 @@ func Load() Config {
|
||||
OneOSDefaultMenus: splitCSV(env("ONEOS_SSO_DEFAULT_MENUS", "vehicles")),
|
||||
OneOSRequestTimeout: time.Duration(envInt("ONEOS_SSO_REQUEST_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
OneOSSessionTTL: time.Duration(envInt("ONEOS_SSO_SESSION_TTL_MINUTES", 30)) * time.Minute,
|
||||
OneOSProfileEnabled: envBool("ONEOS_PROFILE_ENABLED", false),
|
||||
OneOSAuthEnabled: envBool("ONEOS_AUTH_ENABLED", false),
|
||||
OneOSProfileBaseURL: os.Getenv("ONEOS_PROFILE_BASE_URL"),
|
||||
OneOSProfileServiceToken: os.Getenv("ONEOS_PROFILE_SERVICE_TOKEN"),
|
||||
OneOSProfileIssuer: os.Getenv("ONEOS_PROFILE_ISSUER"),
|
||||
OneOSProfileAllowedTenant: os.Getenv("ONEOS_PROFILE_ALLOWED_TENANT"),
|
||||
OneOSProfileTimeout: time.Duration(envInt("ONEOS_PROFILE_TIMEOUT_MS", 3500)) * time.Millisecond,
|
||||
OneOSPublicOrigin: os.Getenv("ONEOS_PUBLIC_ORIGIN"),
|
||||
OneOSPasswordEnabled: envBool("ONEOS_PASSWORD_ENABLED", false),
|
||||
OneOSPasswordClientID: os.Getenv("ONEOS_PASSWORD_CLIENT_ID"),
|
||||
OneOSPasswordRSAPublicKey: os.Getenv("ONEOS_PASSWORD_RSA_PUBLIC_KEY"),
|
||||
OneOSLoginTicketExchangePath: os.Getenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH"),
|
||||
OneOSLoginTicketServiceToken: os.Getenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN"),
|
||||
OneOSDevIntegration: envBool("ONEOS_DEV_INTEGRATION", false),
|
||||
OneOSLocalBackend: envBool("ONEOS_LOCAL_BACKEND", false),
|
||||
OneOSVehicleQueryScope: envBool("ONEOS_VEHICLE_QUERY_SCOPE", false),
|
||||
OneOSDevDataProxyURL: os.Getenv("ONEOS_DEV_DATA_PROXY_URL"),
|
||||
OneOSDevDataProxyToken: os.Getenv("ONEOS_DEV_DATA_PROXY_TOKEN"),
|
||||
OneOSLocalOnly: envBool("ONEOS_LOCAL_ONLY", false),
|
||||
OneOSVehicleScopePath: os.Getenv("ONEOS_VEHICLE_SCOPE_PATH"),
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
|
||||
@@ -79,3 +79,12 @@ func TestLoadReadsHistoryCleanupAutomationSettings(t *testing.T) {
|
||||
t.Fatalf("history cleanup automation settings were not loaded: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsOneOSLoginTicketServiceToken(t *testing.T) {
|
||||
t.Setenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH", "/auth/inner/v1/sso/vehicle-platform/token-exchange")
|
||||
t.Setenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN", "local-fixture-service-credential")
|
||||
cfg := Load()
|
||||
if cfg.OneOSLoginTicketExchangePath != "/auth/inner/v1/sso/vehicle-platform/token-exchange" || cfg.OneOSLoginTicketServiceToken != "local-fixture-service-credential" {
|
||||
t.Fatal("OneOS login-ticket service configuration was not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Package gzev collects Guangzhou platform daily mileage without browser automation.
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Origin = "https://gzev.d.lnoneos.com"
|
||||
const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics"
|
||||
|
||||
var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login")
|
||||
var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`)
|
||||
|
||||
// Decimal preserves source decimal precision and distinguishes missing values from zero.
|
||||
type Decimal struct {
|
||||
Value string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (d *Decimal) UnmarshalJSON(b []byte) error {
|
||||
*d = Decimal{}
|
||||
if string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
s := string(b)
|
||||
if len(s) > 0 && s[0] == '"' {
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !decimalPattern.MatchString(s) {
|
||||
return errors.New("invalid nonnegative mileage decimal")
|
||||
}
|
||||
d.Value = s
|
||||
d.Valid = true
|
||||
return nil
|
||||
}
|
||||
func (d Decimal) MarshalJSON() ([]byte, error) {
|
||||
if !d.Valid {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(d.Value)
|
||||
}
|
||||
func (d Decimal) SQLValue() any {
|
||||
if !d.Valid {
|
||||
return nil
|
||||
}
|
||||
return d.Value
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plateNumber"`
|
||||
VehicleSeq string `json:"vehicleSeq"`
|
||||
ProductionCompany string `json:"productionCompany"`
|
||||
OperationCompany string `json:"operationCompany"`
|
||||
Brand string `json:"brandName"`
|
||||
Model string `json:"brandModel"`
|
||||
DateRange string `json:"dateRange"`
|
||||
Total Decimal `json:"runMileage"`
|
||||
Hydrogen Decimal `json:"hyRunMileage"`
|
||||
Charge Decimal `json:"chargeRunMileage"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
type Day struct {
|
||||
Date string `json:"date"`
|
||||
Rows []Row `json:"rows"`
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
type SessionCookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type Session struct {
|
||||
Authorization string `json:"authorization"`
|
||||
Cookies []SessionCookie `json:"cookies"`
|
||||
}
|
||||
|
||||
func ReadSession(path string) (Session, error) {
|
||||
var s Session
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return s, errors.New("session file unavailable")
|
||||
}
|
||||
if info.Mode().Perm()&0077 != 0 {
|
||||
return s, errors.New("session file must have permissions 0600")
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s, errors.New("cannot read session file")
|
||||
}
|
||||
if json.Unmarshal(b, &s) != nil {
|
||||
return s, errors.New("invalid session JSON")
|
||||
}
|
||||
return s, ValidateSession(s)
|
||||
}
|
||||
|
||||
func ValidateSession(s Session) error {
|
||||
if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") {
|
||||
return errors.New("invalid session authorization")
|
||||
}
|
||||
seenCookies := map[string]bool{}
|
||||
for _, cookie := range s.Cookies {
|
||||
if seenCookies[cookie.Name] {
|
||||
return errors.New("duplicate session cookie")
|
||||
}
|
||||
seenCookies[cookie.Name] = true
|
||||
if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization {
|
||||
return errors.New("token cookie and bearer mismatch")
|
||||
}
|
||||
if cookie.Name != "token" && cookie.Name != "bigs" {
|
||||
return errors.New("session contains unsupported cookie")
|
||||
}
|
||||
if strings.ContainsAny(cookie.Value, "\r\n;") {
|
||||
return errors.New("invalid session cookie")
|
||||
}
|
||||
}
|
||||
if !seenCookies["token"] {
|
||||
return errors.New("token cookie is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func ShanghaiToday(now time.Time) time.Time {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
n := now.In(loc)
|
||||
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
func ParseDate(s string) (time.Time, error) {
|
||||
return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
}
|
||||
func ValidateDate(s string, now time.Time) error {
|
||||
d, e := ParseDate(s)
|
||||
if e != nil || !d.Before(ShanghaiToday(now)) {
|
||||
return errors.New("date must be a completed Asia/Shanghai calendar day")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
Session Session
|
||||
baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin.
|
||||
PageSize int
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
func NewClient(s Session) *Client {
|
||||
return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond}
|
||||
}
|
||||
func wait(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) {
|
||||
q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", c.Session.Authorization)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for _, cookie := range c.Session.Cookies {
|
||||
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if attempt < 2 {
|
||||
continue
|
||||
}
|
||||
return nil, errors.New("GZEV network request failed")
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||
if attempt < 2 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, errors.New("GZEV response read failed")
|
||||
}
|
||||
var envelope struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(b, &envelope) != nil {
|
||||
return nil, errors.New("GZEV non-JSON response; login or upstream failure")
|
||||
}
|
||||
code := strings.Trim(string(envelope.Code), `"`)
|
||||
if code == "401" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if code != "0" {
|
||||
if code == "00000004" || code == "403" {
|
||||
return nil, errors.New("GZEV account lacks endpoint permission")
|
||||
}
|
||||
return nil, errors.New("GZEV non-success business response")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("GZEV retries exhausted")
|
||||
}
|
||||
func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) {
|
||||
day := Day{Date: date, FetchedAt: time.Now().UTC()}
|
||||
if err := wait(ctx, c.Delay); err != nil {
|
||||
return day, err
|
||||
}
|
||||
if err := ValidateDate(date, time.Now()); err != nil {
|
||||
return day, err
|
||||
}
|
||||
if c.PageSize < 1 || c.PageSize > 500 {
|
||||
return day, errors.New("page size must be 1..500")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
expected := -1
|
||||
for page := 1; page <= 10000; page++ {
|
||||
if page > 1 {
|
||||
if err := wait(ctx, c.Delay); err != nil {
|
||||
return day, err
|
||||
}
|
||||
}
|
||||
b, err := c.page(ctx, date, page)
|
||||
if err != nil {
|
||||
return day, err
|
||||
}
|
||||
var env struct {
|
||||
Data *struct {
|
||||
PageNum int `json:"pageNum"`
|
||||
Total *int `json:"total"`
|
||||
List []json.RawMessage `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page {
|
||||
return day, errors.New("invalid pagination schema")
|
||||
}
|
||||
p := env.Data
|
||||
if expected < 0 {
|
||||
expected = *p.Total
|
||||
}
|
||||
if expected < 0 || expected > 1000000 || *p.Total != expected {
|
||||
return day, errors.New("source total changed or is invalid; retry day")
|
||||
}
|
||||
for _, raw := range p.List {
|
||||
var row Row
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return day, fmt.Errorf("invalid mileage row: %w", err)
|
||||
}
|
||||
if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date {
|
||||
return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range")
|
||||
}
|
||||
row.Raw = raw
|
||||
seen[row.VIN] = true
|
||||
day.Rows = append(day.Rows, row)
|
||||
}
|
||||
day.Pages = append(day.Pages, b)
|
||||
if len(day.Rows) == expected {
|
||||
return day, nil
|
||||
}
|
||||
if len(day.Rows) > expected || len(p.List) == 0 {
|
||||
return day, errors.New("incomplete or inconsistent pagination")
|
||||
}
|
||||
}
|
||||
return day, errors.New("pagination safety limit exceeded")
|
||||
}
|
||||
|
||||
// VerifyAccount prevents an operator importing another account's valid session into this dataset.
|
||||
func (c *Client) VerifyAccount(ctx context.Context, account string) error {
|
||||
q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return errors.New("cannot create identity request")
|
||||
}
|
||||
req.Header.Set("Authorization", c.Session.Authorization)
|
||||
for _, cookie := range c.Session.Cookies {
|
||||
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("GZEV identity request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
return ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return errors.New("GZEV identity HTTP failure")
|
||||
}
|
||||
var body struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Data struct {
|
||||
LoginName string `json:"loginname"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
|
||||
return errors.New("invalid identity response")
|
||||
}
|
||||
code := strings.Trim(string(body.Code), `"`)
|
||||
if code == "401" {
|
||||
return ErrAuth
|
||||
}
|
||||
if code != "0" || body.Data.LoginName != account {
|
||||
return errors.New("GZEV session account does not match configured account, or identity check denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecimalPreservesNullAndZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
valid bool
|
||||
value string
|
||||
}{{"null", false, ""}, {`"0.0"`, true, "0.0"}, {"203.125", true, "203.125"}} {
|
||||
var d Decimal
|
||||
if err := json.Unmarshal([]byte(tc.in), &d); err != nil || d.Valid != tc.valid || d.Value != tc.value {
|
||||
t.Fatalf("%s: %+v %v", tc.in, d, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`""`, `"NaN"`, `-1`, `"1.2345"`, `"100000000000"`} {
|
||||
var d Decimal
|
||||
if json.Unmarshal([]byte(bad), &d) == nil {
|
||||
t.Fatalf("accepted %s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestDateShanghaiBoundary(t *testing.T) {
|
||||
now := time.Date(2026, 9, 27, 16, 0, 0, 0, time.UTC)
|
||||
if ValidateDate("2026-09-27", now) != nil || ValidateDate("2026-09-28", now) == nil {
|
||||
t.Fatal("Shanghai day cutoff incorrect")
|
||||
}
|
||||
}
|
||||
func TestFetchPaginationAndCookie(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer fake" {
|
||||
t.Error("missing bearer")
|
||||
}
|
||||
cookie, e := r.Cookie("token")
|
||||
if e != nil || cookie.Value != "fake" {
|
||||
t.Error("missing token cookie")
|
||||
}
|
||||
if r.URL.Query().Get("queryBeginDate") != "2024-01-01" || r.URL.Query().Get("queryEndDate") != "2024-01-01" || r.URL.Query().Get("queryType") != "2" {
|
||||
t.Error("wrong daily query")
|
||||
}
|
||||
fmt.Fprintf(w, `{"code":"0","data":{"pageNum":%d,"total":2,"list":[{"vin":"LNXNEGRR7SR31945%d","dateRange":"2024-01-01 - 2024-01-01","runMileage":"12.1","hyRunMileage":null,"chargeRunMileage":"0"}]}}`, calls, calls)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake", Cookies: []SessionCookie{{"token", "fake"}}})
|
||||
c.baseURL = srv.URL
|
||||
c.PageSize = 1
|
||||
c.Delay = 0
|
||||
day, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
if err != nil || len(day.Rows) != 2 || len(day.Pages) != 2 || calls != 2 {
|
||||
t.Fatalf("pagination: %+v %v", day, err)
|
||||
}
|
||||
if day.Rows[0].Hydrogen.Valid || !day.Rows[0].Charge.Valid {
|
||||
t.Fatal("null lost")
|
||||
}
|
||||
}
|
||||
func TestRejectsIncompleteSource(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"missing code": `{"data":{}}`,
|
||||
"missing total": `{"code":"0","data":{"pageNum":1,"list":[]}}`,
|
||||
"empty page": `{"code":"0","data":{"pageNum":1,"total":2,"list":[]}}`,
|
||||
"wrong date": `{"code":"0","data":{"pageNum":1,"total":1,"list":[{"vin":"LNXNEGRR7SR319456","dateRange":"2024-01-01 - 2024-01-02"}]}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{})
|
||||
c.baseURL = srv.URL
|
||||
if _, err := c.FetchDay(context.Background(), "2024-01-01"); err == nil {
|
||||
t.Fatal("accepted incomplete day")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAuthAndPermissionAreDifferent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
auth bool
|
||||
}{{"401", true}, {"00000004", false}, {"403", false}} {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; fmt.Fprintf(w, `{"code":%q}`, tc.code) }))
|
||||
c := NewClient(Session{})
|
||||
c.baseURL = srv.URL
|
||||
_, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
srv.Close()
|
||||
if err == nil || errors.Is(err, ErrAuth) != tc.auth || calls != 1 {
|
||||
t.Fatalf("code %s: %v calls=%d", tc.code, err, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNoCredentialRedirect(t *testing.T) {
|
||||
hit := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true }))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }))
|
||||
defer source.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake"})
|
||||
c.baseURL = source.URL
|
||||
_, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
if hit || !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("redirect leaked credentials: %v", err)
|
||||
}
|
||||
}
|
||||
func TestReadSessionPermissionsAndCookieAllowlist(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "session.json")
|
||||
body := `{"authorization":"Bearer fake","cookies":[{"name":"token","value":"fake"}]}`
|
||||
os.WriteFile(p, []byte(body), 0644)
|
||||
if _, err := ReadSession(p); err == nil {
|
||||
t.Fatal("accepted world-readable session")
|
||||
}
|
||||
os.Chmod(p, 0600)
|
||||
if _, err := ReadSession(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(p, []byte(strings.ReplaceAll(body, `"name":"token"`, `"name":"password"`)), 0600)
|
||||
if _, err := ReadSession(p); err == nil {
|
||||
t.Fatal("accepted password cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAccountRejectsOtherLogin(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/evdata-sso/api/base/getUserInfo" || r.URL.Query().Get("token") != "fake" {
|
||||
t.Error("incorrect identity contract")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","data":{"loginname":"different-account"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake"})
|
||||
c.baseURL = srv.URL
|
||||
if c.VerifyAccount(context.Background(), "guangdonglingniu") == nil {
|
||||
t.Fatal("mixed account data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReportDay struct {
|
||||
Date string
|
||||
Rows, Known int64
|
||||
}
|
||||
|
||||
type DailyReport struct {
|
||||
Date, From, Through, Latest, LatestKnown, LastSuccess string
|
||||
Expected, Fetched, RetryDates int
|
||||
AuthExpired bool
|
||||
Recent []ReportDay
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// LoadDailyReport reads one consistent database snapshot. NULL mileage stays missing;
|
||||
// a reported zero counts as known data.
|
||||
func LoadDailyReport(ctx context.Context, db *sql.DB, account, from string, now time.Time) (DailyReport, error) {
|
||||
today := ShanghaiToday(now)
|
||||
first, err := ParseDate(from)
|
||||
if err != nil || !first.Before(today) {
|
||||
return DailyReport{}, errors.New("report start must be before today")
|
||||
}
|
||||
r := DailyReport{Date: today.Format("2006-01-02"), From: from, Through: today.AddDate(0, 0, -1).Format("2006-01-02")}
|
||||
r.Expected = int(today.Sub(first).Hours() / 24)
|
||||
tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),last_success_at FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ? ORDER BY stat_date`, account, from, r.Through)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var latestSuccess time.Time
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var success time.Time
|
||||
if err = rows.Scan(&date, &success); err != nil {
|
||||
rows.Close()
|
||||
return r, err
|
||||
}
|
||||
seen[date] = true
|
||||
r.Fetched++
|
||||
r.Latest = date
|
||||
if success.After(latestSuccess) {
|
||||
latestSuccess = success
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if !latestSuccess.IsZero() {
|
||||
r.LastSuccess = latestSuccess.In(today.Location()).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
for d := first; d.Before(today); d = d.AddDate(0, 0, 1) {
|
||||
date := d.Format("2006-01-02")
|
||||
if !seen[date] {
|
||||
r.Missing = append(r.Missing, date)
|
||||
}
|
||||
}
|
||||
var known sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(MAX(stat_date),'%Y-%m-%d') FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? AND hydrogen_km IS NOT NULL`, account, from, r.Through).Scan(&known)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.LatestKnown = known.String
|
||||
recentFrom := today.AddDate(0, 0, -7).Format("2006-01-02")
|
||||
if recentFrom < from {
|
||||
recentFrom = from
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),COUNT(*),COUNT(hydrogen_km) FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? GROUP BY stat_date ORDER BY stat_date`, account, recentFrom, r.Through)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var d ReportDay
|
||||
if err = rows.Scan(&d.Date, &d.Rows, &d.Known); err != nil {
|
||||
rows.Close()
|
||||
return r, err
|
||||
}
|
||||
r.Recent = append(r.Recent, d)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT active FROM gzev_auth_alert WHERE account=?`, account).Scan(&r.AuthExpired)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return r, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gzev_mileage_attempt WHERE account=? AND stat_date BETWEEN ? AND ?`, account, from, r.Through).Scan(&r.RetryDates)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
return r, tx.Commit()
|
||||
}
|
||||
|
||||
func (r DailyReport) Message() (string, string) {
|
||||
conclusion := "同步已覆盖全部目标日期。"
|
||||
switch {
|
||||
case r.AuthExpired:
|
||||
conclusion = "登录已失效,需要更新登录状态;已入库数据仍保留。"
|
||||
case len(r.Missing) > 0:
|
||||
conclusion = fmt.Sprintf("同步尚未补齐,仍有 %d 个日期待采集。", len(r.Missing))
|
||||
default:
|
||||
for _, d := range r.Recent {
|
||||
if d.Date == r.Through {
|
||||
if d.Known == 0 {
|
||||
conclusion = "日期已补齐,但昨天上游纯氢里程全部为空,仍需后续重采。"
|
||||
} else if d.Known < d.Rows {
|
||||
conclusion = "日期已补齐,但昨天仍有部分车辆的上游纯氢里程缺失。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
show := func(v string) string {
|
||||
if v == "" {
|
||||
return "暂无"
|
||||
}
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "**结论:%s**\n\n统计截至:%s(北京时间,昨天及更早完整自然日)\n\n日期采集:%d / %d 天;最新采集日期:%s\n\n最新有纯氢里程值的日期:%s\n\n最后成功采集:%s(北京时间)\n\n待补日期:%d 天;失败待重试:%d 天\n", conclusion, r.Through, r.Fetched, r.Expected, show(r.Latest), show(r.LatestKnown), show(r.LastSuccess), len(r.Missing), r.RetryDates)
|
||||
if len(r.Missing) > 0 {
|
||||
n := len(r.Missing)
|
||||
if n > 14 {
|
||||
n = 14
|
||||
}
|
||||
fmt.Fprintf(&b, "\n待补日期(最多列出14天):%s\n", strings.Join(r.Missing[:n], "、"))
|
||||
}
|
||||
if r.AuthExpired {
|
||||
fmt.Fprintf(&b, "\n[更新广州平台登录状态](https://vehicle.d.lnoneos.com/gzev/session/)\n")
|
||||
}
|
||||
b.WriteString("\n最近7天:\n\n| 日期 | 车辆日记录 | 纯氢有值 | 纯氢缺失 |\n|---|---:|---:|---:|\n")
|
||||
for _, d := range r.Recent {
|
||||
fmt.Fprintf(&b, "| %s | %d | %d | %d |\n", d.Date, d.Rows, d.Known, d.Rows-d.Known)
|
||||
}
|
||||
b.WriteString("\n有值包含平台明确上报的 0;缺失不计为 0。日期采集完成不代表上游字段齐全。每小时 :15 持续同步及重采近期数据。")
|
||||
return "纯氢里程每日汇报 · " + r.Date, b.String()
|
||||
}
|
||||
|
||||
type reportReceipt struct {
|
||||
Date string `json:"date"`
|
||||
AttemptedAt time.Time `json:"attempted_at"`
|
||||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||||
}
|
||||
|
||||
// Persist the attempt before sending: an ambiguous timeout must not cause a
|
||||
// duplicate push when the same day's service is started again.
|
||||
func DeliverDailyReport(path, date string, send func() error) (bool, error) {
|
||||
sent := false
|
||||
err := recoveryLock(path, func() error {
|
||||
var receipt reportReceipt
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(b, &receipt); err != nil {
|
||||
return errors.New("invalid daily report receipt")
|
||||
}
|
||||
if receipt.Date == date {
|
||||
if receipt.AcceptedAt != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("daily report already attempted; delivery acceptance unknown, inspect before retrying")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
receipt = reportReceipt{Date: date, AttemptedAt: time.Now().UTC()}
|
||||
write := func() error {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".daily-report-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
err = json.NewEncoder(f).Encode(receipt)
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
if err = write(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = send(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
receipt.AcceptedAt = &now
|
||||
if err = write(); err != nil {
|
||||
return err
|
||||
}
|
||||
sent = true
|
||||
return nil
|
||||
})
|
||||
return sent, err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestDailyReportSnapshotAndMissingDates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FROM gzev_mileage_day").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "success"}).AddRow("2026-10-21", time.Date(2026, 10, 23, 2, 0, 0, 0, time.UTC)).AddRow("2026-10-22", time.Date(2026, 10, 23, 2, 1, 0, 0, time.UTC)))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*MAX.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-10-21"))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*COUNT.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "rows", "known"}).AddRow("2026-10-21", 426, 1).AddRow("2026-10-22", 426, 0))
|
||||
mock.ExpectQuery("SELECT active FROM gzev_auth_alert").WithArgs("account").WillReturnRows(sqlmock.NewRows([]string{"active"}).AddRow(false))
|
||||
mock.ExpectQuery("SELECT COUNT.*FROM gzev_mileage_attempt").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectCommit()
|
||||
r, err := LoadDailyReport(context.Background(), db, "account", "2026-10-20", time.Date(2026, 10, 23, 2, 30, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Date != "2026-10-23" || r.Expected != 3 || r.Fetched != 2 || len(r.Missing) != 1 || r.Missing[0] != "2026-10-20" || r.LastSuccess != "2026-10-23 10:01:00" || r.RetryDates != 1 {
|
||||
t.Fatalf("incorrect snapshot: %+v", r)
|
||||
}
|
||||
_, body := r.Message()
|
||||
if !strings.Contains(body, "仍有 1 个日期待采集") || !strings.Contains(body, "| 2026-10-22 | 426 | 0 | 426 |") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportConclusionDistinguishesCoverageFromNull(t *testing.T) {
|
||||
r := DailyReport{Date: "2026-10-23", Through: "2026-10-22", Recent: []ReportDay{{Date: "2026-10-22", Rows: 426, Known: 0}}}
|
||||
_, body := r.Message()
|
||||
if !strings.Contains(body, "昨天上游纯氢里程全部为空") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
r.Recent[0].Known = 426
|
||||
_, body = r.Message()
|
||||
if !strings.Contains(body, "同步已覆盖全部目标日期") || strings.Contains(body, "昨天上游纯氢里程全部为空") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
r.AuthExpired = true
|
||||
_, body = r.Message()
|
||||
if !strings.Contains(body, "登录已失效") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportDeliveryDeduplicatesAndPreservesAmbiguousAttempt(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "accepted", true: "timeout"}[fail], func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "report.json")
|
||||
calls := 0
|
||||
send := func() error {
|
||||
calls++
|
||||
if fail {
|
||||
return errors.New("timeout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sent, err := DeliverDailyReport(path, "2026-10-23", send)
|
||||
if sent == fail || (err != nil) != fail {
|
||||
t.Fatalf("sent=%v err=%v", sent, err)
|
||||
}
|
||||
sent, err = DeliverDailyReport(path, "2026-10-23", send)
|
||||
if sent || calls != 1 || (err != nil) != fail {
|
||||
t.Fatalf("duplicate was sent: calls=%d err=%v", calls, err)
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("receipt is not private")
|
||||
}
|
||||
if sent, err = DeliverDailyReport(path, "2026-10-24", func() error { calls++; return nil }); !sent || err != nil || calls != 2 {
|
||||
t.Fatal("next day was blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportPushDoesNotCreateRecoveryTicket(t *testing.T) {
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json")}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f, _ := url.ParseQuery(string(b))
|
||||
if f.Get("title") != "daily report" || f.Get("desp") != "summary" {
|
||||
t.Fatal("incorrect report")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"errno":0}}`))}, nil
|
||||
})}
|
||||
if err := s.SendMessage(context.Background(), "daily report", "summary"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(s.SessionPath + ".recovery.json"); !os.IsNotExist(err) {
|
||||
t.Fatal("daily summary created a recovery ticket")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recoveryTicket struct {
|
||||
Code string `json:"code"`
|
||||
Expires time.Time `json:"expires"`
|
||||
}
|
||||
|
||||
func recoveryLock(path string, fn func() error) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return err
|
||||
}
|
||||
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
return fn()
|
||||
}
|
||||
func EnsureRecoveryCode(path string) (string, error) {
|
||||
var code string
|
||||
err := recoveryLock(path, func() error {
|
||||
b, err := os.ReadFile(path)
|
||||
var ticket recoveryTicket
|
||||
if err == nil && json.Unmarshal(b, &ticket) == nil && ticket.Expires.After(time.Now()) && len(ticket.Code) == 48 {
|
||||
code = ticket.Code
|
||||
return nil
|
||||
}
|
||||
nonce := make([]byte, 24)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
code = hex.EncodeToString(nonce)
|
||||
ticket = recoveryTicket{Code: code, Expires: time.Now().Add(48 * time.Hour)}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".recovery-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err := json.NewEncoder(f).Encode(ticket); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
})
|
||||
return code, err
|
||||
}
|
||||
|
||||
// ConsumeRecovery serializes all writers and only consumes a ticket after a successful update.
|
||||
func ConsumeRecovery(path, code string, action func() error) error {
|
||||
return recoveryLock(path, func() error {
|
||||
b, err := os.ReadFile(path)
|
||||
var ticket recoveryTicket
|
||||
if err != nil || json.Unmarshal(b, &ticket) != nil || !ticket.Expires.After(time.Now()) || len(code) != 48 || subtle.ConstantTimeCompare([]byte(code), []byte(ticket.Code)) != 1 {
|
||||
return errors.New("invalid recovery code")
|
||||
}
|
||||
if err := action(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerChan struct {
|
||||
SendKey, SessionPath, UploadURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func ServerChanFromEnv() ServerChan {
|
||||
path := os.Getenv("GZEV_SESSION_FILE")
|
||||
if path == "" {
|
||||
path = "/var/lib/lingniu-gzev/session.json"
|
||||
}
|
||||
return ServerChan{SendKey: strings.TrimSpace(os.Getenv("SERVERCHAN_SENDKEY")), SessionPath: path, UploadURL: os.Getenv("GZEV_SESSION_UPLOAD_URL"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func (s ServerChan) Send(ctx context.Context) error { return s.send(ctx, false) }
|
||||
func (s ServerChan) SendTest(ctx context.Context) error { return s.send(ctx, true) }
|
||||
func (s ServerChan) send(ctx context.Context, test bool) error {
|
||||
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
|
||||
return errors.New("ServerChan SendKey missing or invalid")
|
||||
}
|
||||
if s.UploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
|
||||
return errors.New("unsupported recovery URL")
|
||||
}
|
||||
code, err := EnsureRecoveryCode(s.SessionPath + ".recovery.json")
|
||||
if err != nil {
|
||||
return errors.New("cannot create recovery code")
|
||||
}
|
||||
title := "广州里程采集登录失效"
|
||||
intro := "定时采集前验证未通过,当前采集已暂停。请登录广州平台并提交新的登录状态。"
|
||||
if test {
|
||||
title = "广州里程采集推送测试"
|
||||
intro = "这是 Server酱推送渠道连通性测试,不表示登录状态已失效。以后采集前发现登录失效,将通过此渠道通知。"
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\n[打开登录状态上传页面](%s)\n\n通知恢复码:\n\n`%s`\n\n恢复码有效期 48 小时,成功上传后失效。上传页面支持粘贴 token 或上传会话 JSON;验证通过后,下次定时采集自动继续。请勿转发恢复码。", intro, s.UploadURL, code)
|
||||
return s.SendMessage(ctx, title, body)
|
||||
}
|
||||
|
||||
// SendMessage uses the existing configured channel without creating a recovery ticket.
|
||||
func (s ServerChan) SendMessage(ctx context.Context, title, body string) error {
|
||||
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
|
||||
return errors.New("ServerChan SendKey missing or invalid")
|
||||
}
|
||||
form := url.Values{"title": {title}, "desp": {body}}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://sctapi.ftqq.com/"+s.SendKey+".send", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errors.New("cannot create push request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("ServerChan transport failure; delivery status may be unknown")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
Errno *int `json:"errno"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code == nil {
|
||||
return errors.New("ServerChan did not confirm acceptance")
|
||||
}
|
||||
if *result.Code != 0 {
|
||||
return fmt.Errorf("ServerChan rejected push (code=%d)", *result.Code)
|
||||
}
|
||||
if result.Data.Errno != nil && *result.Data.Errno != 0 {
|
||||
return fmt.Errorf("ServerChan channel error (errno=%d)", *result.Data.Errno)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerChanPushAndAcceptance(t *testing.T) {
|
||||
for _, response := range []string{`{"code":0,"data":{"errno":0}}`, `{"code":40001}`, `{}`, `{"code":0,"data":{"errno":1}}`} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
calls := 0
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if r.Method != "POST" || r.URL.Host != "sctapi.ftqq.com" || r.URL.Path != "/SCTtest12345678.send" {
|
||||
t.Fatal("wrong endpoint")
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
if form.Get("title") != "广州里程采集登录失效" || !strings.Contains(form.Get("desp"), s.UploadURL) || !strings.Contains(form.Get("desp"), "通知恢复码") {
|
||||
t.Fatal("missing actionable content")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: http.Header{}}, nil
|
||||
})}
|
||||
err := s.Send(context.Background())
|
||||
if (err == nil) != (response == `{"code":0,"data":{"errno":0}}`) || calls != 1 {
|
||||
t.Fatalf("unexpected acceptance: %v", err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), s.SendKey) {
|
||||
t.Fatal("key leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestServerChanMissingKeyDoesNotCreateTicket(t *testing.T) {
|
||||
s := ServerChan{SessionPath: filepath.Join(t.TempDir(), "session.json")}
|
||||
if s.Send(context.Background()) == nil {
|
||||
t.Fatal("unconfigured notification accepted")
|
||||
}
|
||||
}
|
||||
func TestServerChanTestIsClearlyLabeled(t *testing.T) {
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f, _ := url.ParseQuery(string(b))
|
||||
if !strings.Contains(f.Get("title"), "测试") || !strings.Contains(f.Get("desp"), "不表示登录状态已失效") {
|
||||
t.Fatal("false expiry alert")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0}`)), Header: http.Header{}}, nil
|
||||
})}
|
||||
if err := s.SendTest(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMS struct {
|
||||
AccessKeyID, AccessKeySecret, SecurityToken, SignName, TemplateCode, TemplateParam string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func SMSFromEnv() SMS {
|
||||
return SMS{AccessKeyID: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"), SecurityToken: os.Getenv("ALIBABA_CLOUD_SECURITY_TOKEN"), SignName: os.Getenv("GZEV_SMS_SIGN_NAME"), TemplateCode: os.Getenv("GZEV_SMS_TEMPLATE_CODE"), TemplateParam: os.Getenv("GZEV_SMS_TEMPLATE_PARAM"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func hashHex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
|
||||
|
||||
// ACS3 native HTTP signing follows Alibaba Cloud's published V3 specification.
|
||||
func signACS3(req *http.Request, payload, accessKey, secret string) {
|
||||
req.Header.Set("x-acs-content-sha256", hashHex([]byte(payload)))
|
||||
headers := map[string]string{"host": req.URL.Host}
|
||||
for key, values := range req.Header {
|
||||
k := strings.ToLower(key)
|
||||
if k == "content-type" || strings.HasPrefix(k, "x-acs-") {
|
||||
headers[k] = strings.TrimSpace(strings.Join(values, ","))
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonical strings.Builder
|
||||
for _, k := range names {
|
||||
fmt.Fprintf(&canonical, "%s:%s\n", k, headers[k])
|
||||
}
|
||||
signed := strings.Join(names, ";")
|
||||
canonicalRequest := req.Method + "\n/\n" + req.URL.RawQuery + "\n" + canonical.String() + "\n" + signed + "\n" + hashHex([]byte(payload))
|
||||
stringToSign := "ACS3-HMAC-SHA256\n" + hashHex([]byte(canonicalRequest))
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
req.Header.Set("Authorization", "ACS3-HMAC-SHA256 Credential="+accessKey+",SignedHeaders="+signed+",Signature="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
func (s SMS) Send(ctx context.Context) error {
|
||||
if s.AccessKeyID == "" || s.AccessKeySecret == "" || s.SignName == "" || s.TemplateCode == "" {
|
||||
return errors.New("Aliyun SMS not configured")
|
||||
}
|
||||
param := s.TemplateParam
|
||||
if param == "" {
|
||||
param = "{}"
|
||||
}
|
||||
var params map[string]string
|
||||
if json.Unmarshal([]byte(param), ¶ms) != nil || params == nil {
|
||||
return errors.New("SMS TemplateParam must be a JSON string map")
|
||||
}
|
||||
// Template must contain these two variables for a user-actionable recovery message.
|
||||
uploadURL := os.Getenv("GZEV_SESSION_UPLOAD_URL")
|
||||
if uploadURL != "" {
|
||||
if uploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
|
||||
return errors.New("unsupported recovery URL")
|
||||
}
|
||||
path := os.Getenv("GZEV_SESSION_FILE")
|
||||
if path == "" {
|
||||
path = "/var/lib/lingniu-gzev/session.json"
|
||||
}
|
||||
code, err := EnsureRecoveryCode(path + ".recovery.json")
|
||||
if err != nil {
|
||||
return errors.New("cannot create recovery code")
|
||||
}
|
||||
params["upload_url"] = uploadURL
|
||||
params["recovery_code"] = code
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
param = string(data)
|
||||
}
|
||||
form := url.Values{"PhoneNumbers": {"16605225837"}, "SignName": {s.SignName}, "TemplateCode": {s.TemplateCode}, "TemplateParam": {param}}
|
||||
payload := form.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://dysmsapi.aliyuncs.com/", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, 16)
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("x-acs-action", "SendSms")
|
||||
req.Header.Set("x-acs-version", "2017-05-25")
|
||||
req.Header.Set("x-acs-date", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
||||
req.Header.Set("x-acs-signature-nonce", hex.EncodeToString(nonce))
|
||||
if s.SecurityToken != "" {
|
||||
req.Header.Set("x-acs-security-token", s.SecurityToken)
|
||||
}
|
||||
signACS3(req, payload, s.AccessKeyID, s.AccessKeySecret)
|
||||
resp, err := s.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("SMS transport failure; delivery status may be unknown")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code string `json:"Code"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code != "OK" {
|
||||
return errors.New("SMS provider did not confirm acceptance")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
func TestSMSRequestAndBusinessFailure(t *testing.T) {
|
||||
for _, code := range []string{"OK", "isv.BUSINESS_LIMIT_CONTROL"} {
|
||||
calls := 0
|
||||
s := SMS{AccessKeyID: "test-id", AccessKeySecret: "test-secret", SecurityToken: "sts-token", SignName: "测试签名", TemplateCode: "SMS_TEST", TemplateParam: `{"system":"广州里程"}`}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
if r.URL.String() != "https://dysmsapi.aliyuncs.com/" || form.Get("PhoneNumbers") != "16605225837" || form.Get("TemplateCode") != "SMS_TEST" {
|
||||
t.Fatal("wrong SMS destination")
|
||||
}
|
||||
if r.Header.Get("x-acs-content-sha256") != hashHex(b) || r.Header.Get("x-acs-action") != "SendSms" || !strings.Contains(r.Header.Get("Authorization"), "x-acs-security-token") {
|
||||
t.Fatal("incorrect signed request")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"Code":"` + code + `"}`)), Header: http.Header{}}, nil
|
||||
})}
|
||||
err := s.Send(context.Background())
|
||||
if (err == nil) != (code == "OK") || calls != 1 {
|
||||
t.Fatalf("%s: %v", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSMSMissingConfigDoesNotSend(t *testing.T) {
|
||||
if (SMS{}).Send(context.Background()) == nil {
|
||||
t.Fatal("missing config reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSIncludesUploadURLAndSingleUseRecovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.json")
|
||||
t.Setenv("GZEV_SESSION_FILE", path)
|
||||
t.Setenv("GZEV_SESSION_UPLOAD_URL", "https://vehicle.d.lnoneos.com/gzev/session/")
|
||||
s := SMS{AccessKeyID: "id", AccessKeySecret: "secret", SignName: "sign", TemplateCode: "SMS_TEST"}
|
||||
var code string
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
var params map[string]string
|
||||
if json.Unmarshal([]byte(form.Get("TemplateParam")), ¶ms) != nil {
|
||||
t.Fatal("invalid template params")
|
||||
}
|
||||
if params["upload_url"] != "https://vehicle.d.lnoneos.com/gzev/session/" || len(params["recovery_code"]) != 48 {
|
||||
t.Fatal("missing actionable recovery information")
|
||||
}
|
||||
code = params["recovery_code"]
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"Code":"OK"}`))}, nil
|
||||
})}
|
||||
if err := s.Send(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err != nil {
|
||||
t.Fatal("SMS code not usable")
|
||||
}
|
||||
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err == nil {
|
||||
t.Fatal("SMS code reused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrBusy = errors.New("another GZEV worker holds the account lock")
|
||||
|
||||
type Store struct {
|
||||
Conn *sql.Conn
|
||||
Account string
|
||||
}
|
||||
|
||||
// WithLock uses a dedicated connection: MySQL advisory locks are connection scoped.
|
||||
func WithLock(ctx context.Context, db *sql.DB, account string, fn func(*Store) error) error {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
sum := sha256.Sum256([]byte(account))
|
||||
key := "gzev:" + hex.EncodeToString(sum[:24])
|
||||
var ok int
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", key).Scan(&ok); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok != 1 {
|
||||
return ErrBusy
|
||||
}
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", key)
|
||||
}()
|
||||
return fn(&Store{Conn: conn, Account: account})
|
||||
}
|
||||
func (s *Store) NeedsDay(ctx context.Context, date string, recent bool) (bool, error) {
|
||||
var cooling bool
|
||||
err := s.Conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM gzev_mileage_attempt WHERE account=? AND stat_date=? AND retry_after>UTC_TIMESTAMP(3))", s.Account, date).Scan(&cooling)
|
||||
if err != nil || cooling {
|
||||
return false, err
|
||||
}
|
||||
if recent {
|
||||
return true, nil
|
||||
}
|
||||
var retry bool
|
||||
err = s.Conn.QueryRowContext(ctx, "SELECT missing_count>0 AND last_success_at<UTC_TIMESTAMP()-INTERVAL 7 DAY FROM gzev_mileage_day WHERE account=? AND stat_date=?", s.Account, date).Scan(&retry)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
func (s *Store) RecordFailure(ctx context.Context, date string) error {
|
||||
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_mileage_attempt(account,stat_date,retry_after) VALUES(?,?,UTC_TIMESTAMP()+INTERVAL 6 HOUR) ON DUPLICATE KEY UPDATE retry_after=VALUES(retry_after)`, s.Account, date)
|
||||
return err
|
||||
}
|
||||
func (s *Store) SaveDay(ctx context.Context, d Day) error {
|
||||
if err := ValidateDate(d.Date, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.Rows) == 0 {
|
||||
return errors.New("empty source day requires review; not marked complete")
|
||||
}
|
||||
raw, err := json.Marshal(d.Pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.Sum256(raw)
|
||||
checksum := hex.EncodeToString(hash[:])
|
||||
tx, err := s.Conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Reject source regressions rather than replacing a previously measured day with nulls.
|
||||
previous, err := tx.QueryContext(ctx, "SELECT vin,total_km IS NOT NULL,hydrogen_km IS NOT NULL,charge_km IS NOT NULL FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := map[string][3]bool{}
|
||||
for previous.Next() {
|
||||
var vin string
|
||||
var flags [3]bool
|
||||
if err := previous.Scan(&vin, &flags[0], &flags[1], &flags[2]); err != nil {
|
||||
previous.Close()
|
||||
return err
|
||||
}
|
||||
old[vin] = flags
|
||||
}
|
||||
err = previous.Err()
|
||||
previous.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range d.Rows {
|
||||
flags := old[r.VIN]
|
||||
if flags[0] && !r.Total.Valid || flags[1] && !r.Hydrogen.Valid || flags[2] && !r.Charge.Valid {
|
||||
return errors.New("source regressed measured mileage to null; previous day retained")
|
||||
}
|
||||
delete(old, r.VIN)
|
||||
}
|
||||
if len(old) > 0 {
|
||||
return errors.New("source vehicle list shrank; previous day retained for review")
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT IGNORE INTO gzev_mileage_snapshot(account,stat_date,snapshot_hash,fetched_at,raw_pages) VALUES(?,?,?,?,?)", s.Account, d.Date, checksum, d.FetchedAt.UTC(), string(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing := 0
|
||||
for _, r := range d.Rows {
|
||||
status := "reported"
|
||||
if !r.Total.Valid || !r.Hydrogen.Valid || !r.Charge.Valid {
|
||||
status = "missing"
|
||||
missing++
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_daily_mileage(account,vin,stat_date,plate_number,vehicle_seq,production_company,operation_company,brand,model,total_km,hydrogen_km,charge_km,quality_status,raw_row,fetched_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.Account, r.VIN, d.Date, r.Plate, r.VehicleSeq, r.ProductionCompany, r.OperationCompany, r.Brand, r.Model, r.Total.SQLValue(), r.Hydrogen.SQLValue(), r.Charge.SQLValue(), status, string(r.Raw), d.FetchedAt.UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_mileage_day(account,stat_date,row_count,missing_count,last_success_at,snapshot_hash) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE row_count=VALUES(row_count),missing_count=VALUES(missing_count),last_success_at=VALUES(last_success_at),snapshot_hash=VALUES(snapshot_hash)`, s.Account, d.Date, len(d.Rows), missing, d.FetchedAt.UTC(), checksum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM gzev_mileage_attempt WHERE account=? AND stat_date=?", s.Account, d.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Store) ResolveAuth(ctx context.Context) error {
|
||||
_, err := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET active=FALSE,resolved_at=UTC_TIMESTAMP(3) WHERE account=? AND active=TRUE", s.Account)
|
||||
return err
|
||||
}
|
||||
|
||||
// AuthFailure persists delivery state. Retry at most hourly and remind at most daily.
|
||||
// A provider timeout is ambiguous: no immediate retry to avoid duplicate notifications.
|
||||
func (s *Store) AuthFailure(ctx context.Context, send func(context.Context) error) error {
|
||||
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_auth_alert(account,first_failed_at) VALUES(?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE first_failed_at=IF(active,first_failed_at,UTC_TIMESTAMP(3)),last_attempt_at=IF(active,last_attempt_at,NULL),last_sent_at=IF(active,last_sent_at,NULL),resolved_at=NULL,active=TRUE`, s.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var due bool
|
||||
err = s.Conn.QueryRowContext(ctx, `SELECT (last_attempt_at IS NULL OR last_attempt_at<=UTC_TIMESTAMP()-INTERVAL 1 HOUR) AND (last_sent_at IS NULL OR last_sent_at<=UTC_TIMESTAMP()-INTERVAL 24 HOUR) FROM gzev_auth_alert WHERE account=?`, s.Account).Scan(&due)
|
||||
if err != nil || !due {
|
||||
return err
|
||||
}
|
||||
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_attempt_at=UTC_TIMESTAMP(3) WHERE account=?", s.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = send(ctx); err != nil {
|
||||
_, persistErr := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_error='Notification delivery failed or not configured' WHERE account=?", s.Account)
|
||||
return errors.Join(fmt.Errorf("auth notification: %w", err), persistErr)
|
||||
}
|
||||
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_sent_at=UTC_TIMESTAMP(3),last_error='' WHERE account=?", s.Account)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveDayRollsBackIfAnyRowFails(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT vin").WithArgs("account", "2024-01-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}))
|
||||
mock.ExpectExec("INSERT IGNORE INTO gzev_mileage_snapshot").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("DELETE FROM gzev_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO gzev_daily_mileage").WillReturnError(errors.New("disk full"))
|
||||
mock.ExpectRollback()
|
||||
d := Day{Date: "2024-01-01", FetchedAt: time.Now(), Pages: []json.RawMessage{json.RawMessage(`{}`)}, Rows: []Row{{VIN: "LNXNEGRR7SR319456", Raw: json.RawMessage(`{}`)}}}
|
||||
if store.SaveDay(context.Background(), d) == nil {
|
||||
t.Fatal("expected transaction failure")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestSaveRejectsNullRegression(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT vin").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}).AddRow("LNXNEGRR7SR319456", true, false, false))
|
||||
mock.ExpectRollback()
|
||||
if store.SaveDay(context.Background(), Day{Date: "2024-01-01", Rows: []Row{{VIN: "LNXNEGRR7SR319456"}}}) == nil {
|
||||
t.Fatal("overwrote measured value with null")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestAuthNotificationCooldown(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(false))
|
||||
if err := store.AuthFailure(context.Background(), func(context.Context) error { t.Fatal("duplicate SMS sent"); return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestAuthNotificationFailureIsPersisted(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE gzev_auth_alert SET last_attempt_at").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE gzev_auth_alert SET last_error").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
if err := store.AuthFailure(context.Background(), func(context.Context) error { return errors.New("not configured") }); err == nil {
|
||||
t.Fatal("lost delivery failure")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadHandler accepts local SSH access or the configured HTTPS origin with a one-use recovery code.
|
||||
type UploadHandler struct {
|
||||
PublicOrigin string
|
||||
RecoveryPath string
|
||||
SessionPath string
|
||||
Account string
|
||||
Verify func(context.Context, Session, string) error
|
||||
csrf string
|
||||
}
|
||||
|
||||
func NewUploadHandler(path, account string) (*UploadHandler, error) {
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UploadHandler{SessionPath: path, Account: account, csrf: hex.EncodeToString(nonce), Verify: func(ctx context.Context, s Session, a string) error { return NewClient(s).VerifyAccount(ctx, a) }}, nil
|
||||
}
|
||||
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// Keep the Origin header on same-origin form POSTs; no-referrer makes
|
||||
// browsers send Origin: null, which the strict check below must reject.
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
local := r.Host == "127.0.0.1:20312" || r.Host == "localhost:20312"
|
||||
public := h.PublicOrigin != "" && "https://"+r.Host == h.PublicOrigin
|
||||
if !local && !public {
|
||||
http.Error(w, "仅允许通过 SSH 安全连接访问", 403)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" && r.URL.Path != "/gzev/session/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
uploadPage.Execute(w, map[string]string{"Account": h.Account, "CSRF": h.csrf, "Public": fmt.Sprint(h.PublicOrigin != "")})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
expectedOrigin := "http://" + r.Host
|
||||
if public {
|
||||
expectedOrigin = h.PublicOrigin
|
||||
}
|
||||
if r.Header.Get("Origin") != expectedOrigin {
|
||||
http.Error(w, "请求来源不正确,请刷新上传页面", 403)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
if err := r.ParseMultipartForm(32 << 10); err != nil {
|
||||
http.Error(w, "文件或表单无效,大小不能超过 32 KB", 400)
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(h.csrf)) != 1 {
|
||||
http.Error(w, "页面已过期,请刷新后重新提交", 403)
|
||||
return
|
||||
}
|
||||
var s Session
|
||||
file, _, fileErr := r.FormFile("session")
|
||||
if fileErr == nil {
|
||||
defer file.Close()
|
||||
dec := json.NewDecoder(file)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
http.Error(w, "会话文件格式错误", 400)
|
||||
return
|
||||
}
|
||||
if dec.Decode(new(any)) != io.EOF {
|
||||
http.Error(w, "会话文件含多余内容", 400)
|
||||
return
|
||||
}
|
||||
} else if fileErr == http.ErrMissingFile {
|
||||
token := strings.TrimSpace(r.FormValue("token"))
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
s = Session{Authorization: "Bearer " + token, Cookies: []SessionCookie{{Name: "token", Value: token}}}
|
||||
if routing := strings.TrimSpace(r.FormValue("routing")); routing != "" {
|
||||
s.Cookies = append(s.Cookies, SessionCookie{Name: "bigs", Value: routing})
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "无法读取上传文件", 400)
|
||||
return
|
||||
}
|
||||
if ValidateSession(s) != nil {
|
||||
http.Error(w, "会话格式错误,只接受 token 和可选的 bigs Cookie", 400)
|
||||
return
|
||||
}
|
||||
action := func() error {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Verify(ctx, s, h.Account); err != nil {
|
||||
return errors.New("verification failed")
|
||||
}
|
||||
return WriteSession(h.SessionPath, s)
|
||||
}
|
||||
var err error
|
||||
if h.PublicOrigin != "" {
|
||||
err = ConsumeRecovery(h.RecoveryPath, r.FormValue("recovery_code"), action)
|
||||
} else {
|
||||
err = action()
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "验证或保存未通过。请检查恢复码、登录状态及账号;现有会话未覆盖。", 422)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, "<!doctype html><html lang=zh-CN><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>更新成功</title><body><h1>登录状态已更新</h1><p>验证通过,下一次定时采集将自动继续补齐里程数据。</p><a href=/>返回</a></body></html>")
|
||||
}
|
||||
func WriteSession(path string, s Session) error {
|
||||
if err := ValidateSession(s); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".session-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err := json.NewEncoder(f).Encode(s); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
|
||||
var uploadPage = template.Must(template.New("upload").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>更新广州平台登录状态</title><style>body{font:16px/1.6 system-ui,sans-serif;background:#f4f6f8;color:#14212e;margin:0;padding:32px 16px}main{max-width:620px;margin:0 auto;background:white;padding:32px;border-radius:16px}h1{font-size:26px}label{display:block;margin-top:18px}input{display:block;box-sizing:border-box;width:100%;padding:12px;border:1px solid #aab5c0;border-radius:6px;font:inherit}button{margin-top:24px;background:#125acf;color:white;border:0;border-radius:8px;padding:12px 24px;font:inherit}small{color:#536273}details{margin-top:24px}hr{border:0;border-top:1px solid #ddd;margin:24px 0}</style></head><body><main><h1>更新广州平台登录状态</h1><p>请先登录广州平台,账号:<strong>{{.Account}}</strong>。</p><p>提交后会验证账号和登录状态。验证通过后,下一次定时采集自动继续。</p><form method="post" enctype="multipart/form-data" autocomplete="off"><input type="hidden" name="csrf" value="{{.CSRF}}">{{if eq .Public "true"}}<label>通知恢复码<input type="password" name="recovery_code" required autocomplete="off" maxlength="48"></label>{{end}}<label>粘贴 token<input type="password" name="token" autocomplete="off" maxlength="8192"></label><label>bigs Cookie(可选)<input type="password" name="routing" autocomplete="off" maxlength="4096"></label><hr><label>或上传会话 JSON 文件<input type="file" name="session" accept=".json,application/json"></label><small>上传文件时优先使用文件内容。不要提交账号密码或短信验证码。</small><br><button type="submit">验证并更新</button></form><details><summary>如何取得 token?</summary><ol><li>在 Chrome 登录广州平台,完成必要的短信和图形验证码。</li><li>打开开发者工具 → Application → Cookies → gzev.d.lnoneos.com。</li><li>复制 token 的值;如有 bigs,也一并复制。</li><li>回到此页面粘贴并提交。</li></ol></details></main></body></html>`))
|
||||
@@ -0,0 +1,102 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func uploadRequest(t *testing.T, h *UploadHandler, origin, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
form.WriteField("csrf", h.csrf)
|
||||
form.WriteField("token", "new-session")
|
||||
form.WriteField("recovery_code", code)
|
||||
form.Close()
|
||||
req := httptest.NewRequest("POST", "https://vehicle.d.lnoneos.com/gzev/session/", &body)
|
||||
req.Header.Set("Content-Type", form.FormDataContentType())
|
||||
req.Header.Set("Origin", origin)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
func TestPublicUploadRequiresCodeAndConsumesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.json")
|
||||
h, _ := NewUploadHandler(path, "expected")
|
||||
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
|
||||
h.RecoveryPath = path + ".recovery.json"
|
||||
calls := 0
|
||||
h.Verify = func(_ context.Context, s Session, account string) error {
|
||||
calls++
|
||||
if account != "expected" || s.Authorization != "Bearer new-session" {
|
||||
t.Fatal("wrong account or session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, ""); r.Code != 422 || calls != 0 {
|
||||
t.Fatal("missing code reached verification")
|
||||
}
|
||||
code, err := EnsureRecoveryCode(h.RecoveryPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, origin := range []string{"https://attacker.invalid", "null", ""} {
|
||||
if r := uploadRequest(t, h, origin, code); r.Code != 403 || calls != 0 {
|
||||
t.Fatalf("untrusted origin %q accepted", origin)
|
||||
}
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 200 {
|
||||
t.Fatalf("upload failed: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
session, err := ReadSession(path)
|
||||
if err != nil || session.Authorization != "Bearer new-session" {
|
||||
t.Fatal("session was not securely saved")
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 422 || calls != 1 {
|
||||
t.Fatal("reused code accepted")
|
||||
}
|
||||
}
|
||||
func TestInvalidSessionDoesNotOverwriteOrConsume(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.json")
|
||||
os.WriteFile(path, []byte("original"), 0600)
|
||||
h, _ := NewUploadHandler(path, "expected")
|
||||
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
|
||||
h.RecoveryPath = path + ".recovery.json"
|
||||
h.Verify = func(context.Context, Session, string) error { return errors.New("expired credential") }
|
||||
code, _ := EnsureRecoveryCode(h.RecoveryPath)
|
||||
r := uploadRequest(t, h, h.PublicOrigin, code)
|
||||
if r.Code != 422 {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "original" {
|
||||
t.Fatal("overwrote valid previous file")
|
||||
}
|
||||
if _, err := os.Stat(h.RecoveryPath); err != nil {
|
||||
t.Fatal("failed upload consumed recovery code")
|
||||
}
|
||||
if strings.Contains(r.Body.String(), "new-session") {
|
||||
t.Fatal("response leaked token")
|
||||
}
|
||||
}
|
||||
func TestUploadRejectsUntrustedHostAndHidesToken(t *testing.T) {
|
||||
h, _ := NewUploadHandler(filepath.Join(t.TempDir(), "session.json"), "expected")
|
||||
r := httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest("GET", "http://attacker.invalid/", nil))
|
||||
if r.Code != 403 {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
r = httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest("GET", "http://127.0.0.1:20312/", nil))
|
||||
if r.Code != 200 || r.Header().Get("Cache-Control") != "no-store" || r.Header().Get("Referrer-Policy") != "same-origin" || !strings.Contains(r.Body.String(), "验证并更新") {
|
||||
t.Fatal("upload UI unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package hydrogen
|
||||
|
||||
import "math"
|
||||
|
||||
// PressureMassKg converts pressure (MPa), temperature (C) and total water volume (L)
|
||||
// using the existing real-gas compressibility equation.
|
||||
func PressureMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) {
|
||||
temperatureK := temperatureC + 273.15
|
||||
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 {
|
||||
return 0, false
|
||||
}
|
||||
a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
|
||||
b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
|
||||
c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
|
||||
|
||||
z := 1.0
|
||||
for index := range a {
|
||||
z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index])
|
||||
}
|
||||
if z <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000
|
||||
mass := density * capacityLiter / 1000
|
||||
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.10.0
|
||||
version: 1.12.2
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
@@ -16,6 +16,90 @@ tags:
|
||||
- name: 开放平台管理
|
||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||
paths:
|
||||
/api/v1/vehicles/operating-data/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 批量查询车辆综合运营数据
|
||||
description: |
|
||||
responseMode省略或FULL保持已上线完整结构;SIMPLE固定返回9个业务字段并在内部按质量过滤。
|
||||
SIMPLE的百公里氢耗字段暂固定null,不进行未经证明可比的日用氢/日里程相除;不影响summary成功计数。
|
||||
SIMPLE summary按所选展示字段可用性统计;FULL按子查询执行结果统计。未选类型对应的SIMPLE字段仍返回null但不参与计数。
|
||||
SIMPLE舍弃陈旧/无可信采集时间的剩余氢量、异常用氢及结转补零;原完整数据和来源状态仍在内部保留。
|
||||
车牌可选,省略或空数组查询所选时间范围均有效授权的全部车辆(取交集),无车辆返回空数组;省略或空数组不受200辆上限限制,不分页、不截断。显式指定车牌最多200项,超出返回BATCH_SIZE_EXCEEDED。规范化后不允许重复。
|
||||
dataTypes省略时查询实时、单日里程和单日用氢;未选块省略。含日统计时dailyDate必填且不得为未来日期。
|
||||
protocolPriority仅影响DAILY_MILEAGE,未选里程时不可提供;实时保持原有选源。
|
||||
appKey须当前有效,实时车辆授权覆盖requestAt,日统计授权完整覆盖查询自然日。任一越权整批403;先授权再读取。
|
||||
各数据块保留源字段、质量与时间,不是共同快照;不计算百公里氢耗。同日或NORMAL不能证明统计区间可比。
|
||||
FULL的queryStatus仅表示子服务可见执行结果,SUCCESS仍可能NO_DATA、DATA_ANOMALY或实时补充降级MISSING/UNKNOWN。
|
||||
里程结转沿用status=NORMAL及dataQuality=CARRIED_FORWARD,不能解释为实测零;储氢陈旧和估算来源原样保留。
|
||||
各块失败独立返回FAILED及错误码,不以NO_DATA或旧值掩盖。所有子查询失败仍返回200和准确summary。
|
||||
所有时间戳转为带+08:00的RFC3339,未知可选字段显式null;原独立接口不受影响。
|
||||
每应用每服务实例30批/60秒、最多2批并发;新端点全局每实例最多16批。429包含Retry-After,多实例配额由网关统一协调。
|
||||
总预算6秒,批量子查询共享最多4.5秒预算;上层网关需给出更长预算。未承诺未经压测的P95目标。
|
||||
operationId: queryVehicleOperatingData
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: 最大64KiB;不接受未知字段或显式null。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingDataQuery'
|
||||
example:
|
||||
responseMode: SIMPLE
|
||||
plateNumbers: [粤A09635F]
|
||||
dailyDate: '2026-10-23'
|
||||
dataTypes: [REALTIME, DAILY_MILEAGE, DAILY_HYDROGEN]
|
||||
protocolPriority: [GB32960, MQTT, JT808]
|
||||
responses:
|
||||
'200':
|
||||
description: 聚合处理完成;FULL逐块检查执行和质量,SIMPLE返回9字段并按所选展示字段可用性汇总。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/OperatingDataResponse'
|
||||
- $ref: '#/components/schemas/OperatingSimpleResponse'
|
||||
'400':
|
||||
description: 参数、日期或协议不合法;显式plateNumbers超过200项时code为BATCH_SIZE_EXCEEDED
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'401':
|
||||
description: appKey当前无效
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'403':
|
||||
description: 车辆或所选时段未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'413':
|
||||
description: 请求体超过64KiB
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'429':
|
||||
description: 应用或全局限额超限
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: {type: integer, minimum: 1}
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'500':
|
||||
description: 授权依赖、身份映射或聚合内部异常
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
/api/v1/vehicles/hydrogen-remaining/history/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
@@ -137,7 +221,7 @@ paths:
|
||||
tags: [合作方数据接口]
|
||||
summary: 分页查询车辆区间日里程
|
||||
description: |
|
||||
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
最长支持 3 年(1096 天)。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
@@ -491,6 +575,391 @@ components:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
schemas:
|
||||
OperatingSimpleVehicle:
|
||||
type: "object"
|
||||
additionalProperties: false
|
||||
required: ["plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"]
|
||||
properties:
|
||||
plateNumber:
|
||||
type: "string"
|
||||
description: "规范化后的请求车牌,保持请求顺序。"
|
||||
reportTime:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "实时选中主记录的event_time,即真实源采集/上报时刻;缺失为null,不使用received_at、updated_at或请求时间替代。FULL recordTime仍保持原兼容口径。"
|
||||
format: "date-time"
|
||||
offline:
|
||||
type: "boolean"
|
||||
nullable: true
|
||||
description: "主实时记录可用且online已知时取反;无记录或查询失败为null,不能当成离线。"
|
||||
remainingHydrogenKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "剩余氢量kg。仅kg字段NORMAL、数值有效且有可信采集时间,响应时不超过300秒并不晚于服务时点1分钟时展示;陈旧/缺失/异常为null,真实0保留。"
|
||||
minimum: 0
|
||||
socPercent:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "动力电池SOC百分比;实时主记录有效且数值在0–100内,未知为null。"
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
totalMileageKm:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "实时主记录累计里程km;不使用日统计累计值兜底。"
|
||||
minimum: 0
|
||||
dailyHydrogenConsumptionKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "queryDate用氢量kg;子查询成功、status=NORMAL、qualityStatus=OK时返回有效非负数值,否则null。日内值可为PRELIMINARY,不保证终报。"
|
||||
minimum: 0
|
||||
dailyMileageKm:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "queryDate里程km;执行成功、业务NORMAL且非CARRIED_FORWARD时返回有效非负数值;实测零保留,结转补零和异常为null。"
|
||||
minimum: 0
|
||||
hydrogenConsumptionPer100KmKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "kg/100km预留字段;当前缺少可比区间完整证据,固定null,不参与summary计数。禁止调用方以同日日指标直接相除。"
|
||||
enum: [null]
|
||||
OperatingSimpleSummary:
|
||||
type: "object"
|
||||
description: "按所选展示字段的可用性统计:实时5项+日里程1项+日用氢1项。全有值successCount、部分有值partialSuccessCount、全null failedCount。plateNumber和暂未支持的百公里氢耗不参与;未选类型不参与。null及failedCount并不等于查询执行失败,不作为自动重试依据。"
|
||||
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
|
||||
properties:
|
||||
requestedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/requestedCount"}
|
||||
returnedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/returnedCount"}
|
||||
successCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/successCount"}
|
||||
partialSuccessCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/partialSuccessCount"}
|
||||
failedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/failedCount"}
|
||||
OperatingSimpleResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
|
||||
properties:
|
||||
code: {"$ref": "#/components/schemas/OperatingDataResponse/properties/code"}
|
||||
message: {"$ref": "#/components/schemas/OperatingDataResponse/properties/message"}
|
||||
traceId: {"$ref": "#/components/schemas/OperatingDataResponse/properties/traceId"}
|
||||
queryDate: {"$ref": "#/components/schemas/OperatingDataResponse/properties/queryDate"}
|
||||
timezone: {"$ref": "#/components/schemas/OperatingDataResponse/properties/timezone"}
|
||||
requestAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/requestAt"}
|
||||
generatedAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/generatedAt"}
|
||||
data:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
items: {"$ref": "#/components/schemas/OperatingSimpleVehicle"}
|
||||
summary: {"$ref": "#/components/schemas/OperatingSimpleSummary"}
|
||||
OperatingDataQuery:
|
||||
type: "object"
|
||||
oneOf:
|
||||
- required: [dataTypes]
|
||||
properties:
|
||||
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
|
||||
- required: [dailyDate]
|
||||
not:
|
||||
required: [dataTypes]
|
||||
properties:
|
||||
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
|
||||
additionalProperties: false
|
||||
properties:
|
||||
responseMode:
|
||||
type: string
|
||||
enum: [FULL, SIMPLE]
|
||||
default: FULL
|
||||
description: 省略保持完整结构;显式SIMPLE返回固定9字段;不接受空字符串或null。
|
||||
plateNumbers:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: "可选;省略或空数组查询所选时间范围内均有效授权的全部车辆,按车牌排序。省略或空数组返回全部有效授权车辆,不受200辆上限限制,不分页、不截断;显式指定最多200项,超出返回BATCH_SIZE_EXCEEDED。去首尾空白、转大写后仍必须唯一。"
|
||||
dailyDate:
|
||||
type: "string"
|
||||
format: "date"
|
||||
description: "含日统计时必填;仅实时可省略;提供时仍校验。无固定历史下界,能否取得结果取决于有效授权及保留的统计投影,不承诺历史完整性。"
|
||||
dataTypes:
|
||||
type: "array"
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
enum: ["REALTIME", "DAILY_MILEAGE", "DAILY_HYDROGEN"]
|
||||
description: "省略默认全部;未选数据块不出现。"
|
||||
protocolPriority:
|
||||
type: "array"
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
enum: ["GB32960", "MQTT", "JT808"]
|
||||
description: "仅选DAILY_MILEAGE时允许;未列出协议不兜底。"
|
||||
OperatingRealtimeVehicleResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "gpsFixStatus", "locationRecordTime", "coordinateSystem", "protocol", "longitude", "latitude", "speedKmh", "socPercent", "totalMileageKm", "recordTime", "timeDifferenceSeconds", "online", "activeToday", "motionStatus", "locationAvailable", "status", "hydrogenPressureTemperatureSource", "remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL", "hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa", "hydrogenEstimateTemperatureC", "hydrogenCalculationVersion", "hydrogenCapacitySource", "hydrogenPercentReason", "remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime", "hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus", "hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds", "hydrogenExpectedIntervalSeconds"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
gpsFixStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/gpsFixStatus"}
|
||||
locationRecordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
coordinateSystem: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/coordinateSystem"}
|
||||
protocol:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
longitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/longitude"}
|
||||
latitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/latitude"}
|
||||
speedKmh: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/speedKmh"}
|
||||
socPercent:
|
||||
type: "number"
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
nullable: true
|
||||
description: "动力电池SOC;未知为null。"
|
||||
totalMileageKm: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/totalMileageKm"}
|
||||
recordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
timeDifferenceSeconds:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
nullable: true
|
||||
description: "源主记录年龄,不表示实际离线时长。"
|
||||
online: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/online"}
|
||||
activeToday: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/activeToday"}
|
||||
motionStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/motionStatus"}
|
||||
locationAvailable: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/locationAvailable"}
|
||||
status: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/status"}
|
||||
hydrogenPressureTemperatureSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPressureTemperatureSource"}
|
||||
remainingHydrogenPercentSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentSource"}
|
||||
hydrogenFullCapacityKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullCapacityKg"}
|
||||
hydrogenTankCapacityL: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenTankCapacityL"}
|
||||
hydrogenFullPressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullPressureMPa"}
|
||||
hydrogenReferenceTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenReferenceTemperatureC"}
|
||||
hydrogenEstimatePressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimatePressureMPa"}
|
||||
hydrogenEstimateTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimateTemperatureC"}
|
||||
hydrogenCalculationVersion: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCalculationVersion"}
|
||||
hydrogenCapacitySource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCapacitySource"}
|
||||
hydrogenPercentReason: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPercentReason"}
|
||||
remainingHydrogenKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKg"}
|
||||
remainingHydrogenPercent: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercent"}
|
||||
hydrogenRecordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
hydrogenDataStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenDataStatus"}
|
||||
remainingHydrogenKgStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKgStatus"}
|
||||
remainingHydrogenPercentStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentStatus"}
|
||||
hydrogenValueSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenValueSource"}
|
||||
hydrogenSourceProtocol: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenSourceProtocol"}
|
||||
hydrogenStaleAfterSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenStaleAfterSeconds"}
|
||||
hydrogenExpectedIntervalSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenExpectedIntervalSeconds"}
|
||||
OperatingMileageResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "date", "dailyMileageKm", "totalMileageKm", "dataTime", "updatedAt", "sourceProtocol", "dataQuality", "status"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
statisticsStartTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
statisticsEndTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
date: {"$ref": "#/components/schemas/MileageResult/properties/date"}
|
||||
dailyMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/dailyMileageKm"}
|
||||
totalMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/totalMileageKm"}
|
||||
dataTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
updatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
sourceProtocol: {"$ref": "#/components/schemas/MileageResult/properties/sourceProtocol"}
|
||||
dataQuality:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
status: {"$ref": "#/components/schemas/MileageResult/properties/status"}
|
||||
OperatingHydrogenResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "updatedAt", "date", "hydrogenConsumptionKg", "calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason", "status"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
statisticsStartTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
statisticsEndTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
updatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
date: {"$ref": "#/components/schemas/HydrogenResult/properties/date"}
|
||||
hydrogenConsumptionKg: {"$ref": "#/components/schemas/HydrogenResult/properties/hydrogenConsumptionKg"}
|
||||
calculationPhase:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
algorithmVersion:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
qualityStatus:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
qualityReason:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
status: {"$ref": "#/components/schemas/HydrogenResult/properties/status"}
|
||||
OperatingDataBlockFailure:
|
||||
type: "object"
|
||||
additionalProperties: false
|
||||
required: ["queryStatus", "status", "errorCode", "errorMessage"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["FAILED"]
|
||||
status:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorCode:
|
||||
type: "string"
|
||||
enum: ["REALTIME_QUERY_FAILED", "REALTIME_QUERY_TIMEOUT", "MILEAGE_QUERY_FAILED", "MILEAGE_QUERY_TIMEOUT", "HYDROGEN_QUERY_FAILED", "HYDROGEN_QUERY_TIMEOUT", "SOURCE_DATA_INVALID"]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
OperatingVehicleResult:
|
||||
type: "object"
|
||||
required: ["vin", "plateNumber"]
|
||||
properties:
|
||||
vin:
|
||||
type: "string"
|
||||
plateNumber:
|
||||
type: "string"
|
||||
realtime:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingRealtimeVehicleResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
dailyMileage:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingMileageResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
dailyHydrogen:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingHydrogenResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
OperatingDataSummary:
|
||||
type: "object"
|
||||
description: "按车辆统计所选块的queryStatus;NO_DATA/异常仍为执行成功。successCount+partialSuccessCount+failedCount=returnedCount=requestedCount。"
|
||||
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
|
||||
properties:
|
||||
requestedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
returnedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
successCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
partialSuccessCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
failedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
OperatingDataResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
|
||||
properties:
|
||||
code:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
message:
|
||||
type: "string"
|
||||
traceId:
|
||||
type: "string"
|
||||
queryDate:
|
||||
type: "string"
|
||||
format: "date"
|
||||
nullable: true
|
||||
description: "包含日统计时为dailyDate;仅实时为null。"
|
||||
timezone:
|
||||
type: "string"
|
||||
enum: ["Asia/Shanghai"]
|
||||
requestAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
generatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
data:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
items: {"$ref": "#/components/schemas/OperatingVehicleResult"}
|
||||
summary: {"$ref": "#/components/schemas/OperatingDataSummary"}
|
||||
OperatingErrorResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId"]
|
||||
properties:
|
||||
code:
|
||||
type: "string"
|
||||
enum: ["INVALID_REQUEST", "BATCH_SIZE_EXCEEDED", "INVALID_DATE_FORMAT", "INVALID_DATE_RANGE", "UNAUTHORIZED", "FORBIDDEN", "PAYLOAD_TOO_LARGE", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR"]
|
||||
message:
|
||||
type: "string"
|
||||
traceId:
|
||||
type: "string"
|
||||
VehicleDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -541,7 +1010,7 @@ components:
|
||||
endDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 1095 天(含首尾共1096天)
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 5000
|
||||
|
||||
@@ -132,3 +132,31 @@ func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataDocumentationAndCatalog(t *testing.T) {
|
||||
for _, text := range []string{OperatingDataQueryPath, "queryStatus", "dailyDate", "partialSuccessCount", "CARRIED_FORWARD", "Retry-After", "MISSING"} {
|
||||
if !strings.Contains(string(openAPISpec), text) {
|
||||
t.Errorf("OpenAPI missing %s", text)
|
||||
}
|
||||
if !strings.Contains(string(simpleDocsHTML), text) {
|
||||
t.Errorf("HTML missing %s", text)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, product := range dataProducts() {
|
||||
if product.Path == OperatingDataQueryPath {
|
||||
found = product.Code == "operating_data"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aggregate API missing from catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingSimpleModeDocumentation(t *testing.T) {
|
||||
for _, field := range []string{"responseMode", "SIMPLE", "reportTime", "offline", "dailyHydrogenConsumptionKg", "hydrogenConsumptionPer100KmKg"} {
|
||||
if !strings.Contains(string(openAPISpec), field) || !strings.Contains(string(simpleDocsHTML), field) {
|
||||
t.Errorf("SIMPLE contract missing %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+OperatingDataQueryPath, h.operatingData)
|
||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
@@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() {
|
||||
|
||||
func NewDataHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.mux.HandleFunc("POST "+OperatingDataQueryPath, handler.operatingData)
|
||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
@@ -125,7 +127,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
return path == OperatingDataQueryPath || path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -720,6 +722,10 @@ func externalTraceID(r *http.Request) string {
|
||||
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
|
||||
return traceID
|
||||
}
|
||||
return newExternalTraceID()
|
||||
}
|
||||
|
||||
func newExternalTraceID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err == nil {
|
||||
return hex.EncodeToString(value[:])
|
||||
@@ -756,6 +762,7 @@ func requestRemoteAddress(r *http.Request) string {
|
||||
|
||||
func dataProducts() []DataProduct {
|
||||
return []DataProduct{
|
||||
{Code: "operating_data", Name: "车辆综合运营数据", Description: "批量聚合实时、单日里程与单日用氢,分别保留质量、时间和执行状态。", Version: "v1", Status: "available", Method: http.MethodPost, Path: OperatingDataQueryPath, Unit: ""},
|
||||
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
|
||||
{
|
||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||
|
||||
@@ -157,22 +157,23 @@ type RealtimeVehicleRequest struct {
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
LiveHydrogen RealtimeHydrogenData
|
||||
LocationObservedAt time.Time
|
||||
LocationEventID string
|
||||
LocationReceivedAt time.Time
|
||||
GPSFixStatus string
|
||||
CoordinateSystem string
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
LiveHydrogen RealtimeHydrogenData
|
||||
LocationObservedAt time.Time
|
||||
LocationEventID string
|
||||
LocationReceivedAt time.Time
|
||||
GPSFixStatus string
|
||||
CoordinateSystem string
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
TotalMileageAvailable *bool
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
}
|
||||
|
||||
type RealtimeVehicleResult struct {
|
||||
|
||||
@@ -63,6 +63,11 @@ WHERE app_key_hash=?
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
// Instant grants expire exclusively; a whole-day window may end at valid_to.
|
||||
endOperator := ">="
|
||||
if dayStart.Equal(dayEnd) {
|
||||
endOperator = ">"
|
||||
}
|
||||
args := make([]any, 0, len(plates)+3)
|
||||
args = append(args, appID, dayStart, dayEnd)
|
||||
query := `
|
||||
@@ -71,7 +76,7 @@ FROM vehicle_open_app_vehicle g
|
||||
JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE g.app_id=?
|
||||
AND g.valid_from<=?
|
||||
AND (g.valid_to IS NULL OR g.valid_to>=?)
|
||||
AND (g.valid_to IS NULL OR g.valid_to` + endOperator + `?)
|
||||
AND TRIM(COALESCE(b.plate,''))<>''`
|
||||
if len(plates) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
|
||||
@@ -195,7 +200,7 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
|
||||
args = append(args, now.Add(-10*time.Minute))
|
||||
query := `
|
||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
||||
COALESCE(l.speed_kmh,0),l.soc_percent,l.total_mileage_km,l.updated_at,
|
||||
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today,
|
||||
l.event_time,l.received_at,l.event_id
|
||||
FROM vehicle_realtime_location l
|
||||
@@ -212,12 +217,15 @@ ORDER BY l.vin,
|
||||
onlineThreshold := now.Add(-time.Minute)
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
var soc sql.NullFloat64
|
||||
var soc, totalMileage sql.NullFloat64
|
||||
var locationAt, receivedAt sql.NullTime
|
||||
var eventID sql.NullString
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &totalMileage, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.TotalMileageKm = totalMileage.Float64 // Preserve the FULL endpoint's compatibility value.
|
||||
available := totalMileage.Valid
|
||||
point.TotalMileageAvailable = &available
|
||||
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
|
||||
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
||||
value := round3(soc.Float64)
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const OperatingDataQueryPath = "/api/v1/vehicles/operating-data/query"
|
||||
const operatingBudget = 6 * time.Second
|
||||
const operatingQueryBudget = 4500 * time.Millisecond
|
||||
|
||||
const (
|
||||
operatingRealtime = "REALTIME"
|
||||
operatingMileage = "DAILY_MILEAGE"
|
||||
operatingHydrogen = "DAILY_HYDROGEN"
|
||||
)
|
||||
|
||||
type OperatingDataRequest struct {
|
||||
ResponseMode string `json:"responseMode,omitempty"`
|
||||
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||
DailyDate string `json:"dailyDate,omitempty"`
|
||||
DataTypes []string `json:"dataTypes,omitempty"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
}
|
||||
|
||||
func (r *OperatingDataRequest) UnmarshalJSON(data []byte) error {
|
||||
type plain OperatingDataRequest
|
||||
var value plain
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("request must be an object")
|
||||
}
|
||||
if _, exists := fields["dailyDate"]; exists && value.DailyDate == "" {
|
||||
return fmt.Errorf("dailyDate must not be empty")
|
||||
}
|
||||
if _, exists := fields["responseMode"]; exists && value.ResponseMode == "" {
|
||||
return fmt.Errorf("responseMode must not be empty")
|
||||
}
|
||||
for key, raw := range fields {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return fmt.Errorf("%s must not be null", key)
|
||||
}
|
||||
}
|
||||
*r = OperatingDataRequest(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RawMessage retains source number precision while normalizing optional fields
|
||||
// without duplicating the existing result DTOs or changing their serializers.
|
||||
type OperatingDataBlock map[string]json.RawMessage
|
||||
|
||||
type OperatingVehicleResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Realtime OperatingDataBlock `json:"realtime,omitempty"`
|
||||
DailyMileage OperatingDataBlock `json:"dailyMileage,omitempty"`
|
||||
DailyHydrogen OperatingDataBlock `json:"dailyHydrogen,omitempty"`
|
||||
}
|
||||
|
||||
type OperatingDataSummary struct {
|
||||
RequestedCount int `json:"requestedCount"`
|
||||
ReturnedCount int `json:"returnedCount"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
PartialSuccessCount int `json:"partialSuccessCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
type OperatingDataResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
TraceID string `json:"traceId"`
|
||||
QueryDate *string `json:"queryDate"`
|
||||
Timezone string `json:"timezone"`
|
||||
RequestAt string `json:"requestAt"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Data []OperatingVehicleResult `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
|
||||
type operatingBatchSizeError struct{}
|
||||
|
||||
func (*operatingBatchSizeError) Error() string { return "plateNumbers exceeds 200" }
|
||||
func (*operatingBatchSizeError) Unwrap() error { return ErrInvalidRequest }
|
||||
|
||||
type operatingDateError struct{ code string }
|
||||
|
||||
func (e *operatingDateError) Error() string { return e.code }
|
||||
|
||||
func (s *Service) validateOperatingData(r OperatingDataRequest, now time.Time) ([]string, map[string]bool, []string, time.Time, error) {
|
||||
if r.ResponseMode != "" && r.ResponseMode != "FULL" && r.ResponseMode != "SIMPLE" {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: responseMode must be FULL or SIMPLE", ErrInvalidRequest)
|
||||
}
|
||||
if len(r.PlateNumbers) > 200 {
|
||||
return nil, nil, nil, time.Time{}, &operatingBatchSizeError{}
|
||||
}
|
||||
plates, err := normalizePlates(r.PlateNumbers, 200)
|
||||
if err != nil {
|
||||
return nil, nil, nil, time.Time{}, err
|
||||
}
|
||||
kinds := r.DataTypes
|
||||
if kinds == nil {
|
||||
kinds = []string{operatingRealtime, operatingMileage, operatingHydrogen}
|
||||
}
|
||||
selected := make(map[string]bool, 3)
|
||||
for _, kind := range kinds {
|
||||
if selected[kind] || (kind != operatingRealtime && kind != operatingMileage && kind != operatingHydrogen) {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: invalid dataTypes", ErrInvalidRequest)
|
||||
}
|
||||
selected[kind] = true
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: empty dataTypes", ErrInvalidRequest)
|
||||
}
|
||||
var day time.Time
|
||||
if r.DailyDate != "" || selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
day, err = time.ParseInLocation("2006-01-02", r.DailyDate, s.location)
|
||||
if err != nil || day.Format("2006-01-02") != r.DailyDate {
|
||||
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_FORMAT"}
|
||||
}
|
||||
if day.After(now) {
|
||||
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_RANGE"}
|
||||
}
|
||||
}
|
||||
if r.ProtocolPriority.Present && !selected[operatingMileage] {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: protocolPriority requires DAILY_MILEAGE", ErrInvalidRequest)
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(r.ProtocolPriority)
|
||||
return plates, selected, protocols, day, err
|
||||
}
|
||||
|
||||
// Validate every selected authorization scope before reading business data. The
|
||||
// same frozen VIN mapping is then reused by all three batched repositories.
|
||||
func (s *Service) authorizeOperatingScopes(ctx context.Context, key string, app AppCredential, plates []string, selected map[string]bool, now, day time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(key)))
|
||||
allVehicles := len(plates) == 0
|
||||
var vehicles map[string]AuthorizedVehicle
|
||||
check := func(start, end time.Time) error {
|
||||
found, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allVehicles && len(found) != len(plates) {
|
||||
return ErrForbidden
|
||||
}
|
||||
checkedPlates := plates
|
||||
if allVehicles {
|
||||
checkedPlates = vehiclePlates(found)
|
||||
}
|
||||
for _, plate := range checkedPlates {
|
||||
v, ok := found[plate]
|
||||
if !ok || strings.TrimSpace(v.VIN) == "" || (v.Plate != "" && v.Plate != plate) {
|
||||
return ErrForbidden
|
||||
}
|
||||
if previous, exists := vehicles[plate]; exists && previous.VIN != v.VIN {
|
||||
return errors.New("operating query identity changed")
|
||||
}
|
||||
}
|
||||
if allVehicles && vehicles != nil {
|
||||
// Only return vehicles authorized for every selected time window.
|
||||
for plate := range vehicles {
|
||||
if _, exists := found[plate]; !exists {
|
||||
delete(vehicles, plate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vehicles = found
|
||||
}
|
||||
if vehicles == nil {
|
||||
vehicles = make(map[string]AuthorizedVehicle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if selected[operatingRealtime] {
|
||||
if err := check(now, now); err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
}
|
||||
if selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
end := day.AddDate(0, 0, 1)
|
||||
historicalApp, err := s.repository.Authenticate(ctx, hash, now, day, end)
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
if historicalApp.ID != app.ID {
|
||||
return app, nil, errors.New("operating query app identity changed")
|
||||
}
|
||||
if err := check(day, end); err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
}
|
||||
return app, vehicles, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryOperatingData(ctx context.Context, key, traceID string, request OperatingDataRequest) (*OperatingDataResponse, error) {
|
||||
now := s.now().In(s.location)
|
||||
plates, selected, protocols, day, err := s.validateOperatingData(request, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, operatingBudget)
|
||||
defer cancel()
|
||||
// Authenticate before applying the existing per-app limiter. Independent state
|
||||
// avoids consuming the historical-hydrogen endpoint's quota.
|
||||
if !appKeyPattern.MatchString(key) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(key))), now, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err := s.operatingLimiter.acquire(app.ID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
select {
|
||||
case s.operatingSlots <- struct{}{}:
|
||||
defer func() { <-s.operatingSlots }()
|
||||
default:
|
||||
return nil, &historicalHydrogenRateError{RetryAfter: 1}
|
||||
}
|
||||
// Authentication is shared with scope checks below, not repeated per type.
|
||||
app, vehicles, err := s.authorizeOperatingScopes(bounded, key, app, plates, selected, now, day)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "denied", traceID, len(plates), "scope validation failed")
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
result := &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: traceID, Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339Nano), Data: make([]OperatingVehicleResult, len(plates)), Summary: OperatingDataSummary{RequestedCount: len(plates), ReturnedCount: len(plates)}}
|
||||
if selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
result.QueryDate = &request.DailyDate
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "success", traceID, 0, "")
|
||||
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
|
||||
return result, nil
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
type completed struct {
|
||||
kind string
|
||||
blocks []OperatingDataBlock
|
||||
}
|
||||
done := make(chan completed, len(selected))
|
||||
queryCtx, queryCancel := context.WithTimeout(bounded, operatingQueryBudget)
|
||||
defer queryCancel()
|
||||
for kind := range selected {
|
||||
go func(kind string) {
|
||||
started := time.Now()
|
||||
var blocks []OperatingDataBlock
|
||||
var err error
|
||||
switch kind {
|
||||
case operatingRealtime:
|
||||
var points map[string]RealtimeVehiclePoint
|
||||
points, err = s.repository.RealtimeVehicles(queryCtx, vins, now)
|
||||
if err == nil {
|
||||
values := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
prepareSimpleRealtime(values, plates, vehicles, points, s.location)
|
||||
}
|
||||
blocks = operatingBlocks(values, s.location)
|
||||
}
|
||||
case operatingMileage:
|
||||
var values map[string]DailyMileage
|
||||
values, err = s.repository.ReconciledMileageRange(queryCtx, vins, request.DailyDate, request.DailyDate, protocols)
|
||||
if err == nil {
|
||||
blocks = operatingBlocks(buildMileageResults(plates, vehicles, values, request.DailyDate), s.location)
|
||||
}
|
||||
case operatingHydrogen:
|
||||
var values map[string]DailyHydrogen
|
||||
values, err = s.repository.DailyHydrogen(queryCtx, vins, request.DailyDate)
|
||||
if err == nil {
|
||||
blocks = operatingBlocks(buildHydrogenResults(plates, vehicles, values, request.DailyDate), s.location)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
blocks = make([]OperatingDataBlock, len(plates))
|
||||
for i := range blocks {
|
||||
blocks[i] = operatingFailure(kind, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("openplatform operating query unavailable: traceId=%s appId=%d type=%s durationMs=%d errorType=%T", traceID, app.ID, kind, time.Since(started).Milliseconds(), err)
|
||||
}
|
||||
done <- completed{kind, blocks}
|
||||
}(kind)
|
||||
}
|
||||
byKind := make(map[string][]OperatingDataBlock, len(selected))
|
||||
for len(byKind) < len(selected) {
|
||||
select {
|
||||
case result := <-done:
|
||||
byKind[result.kind] = result.blocks
|
||||
case <-queryCtx.Done():
|
||||
// Drain already completed work before timing out only the outstanding types.
|
||||
for {
|
||||
select {
|
||||
case result := <-done:
|
||||
byKind[result.kind] = result.blocks
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
for kind := range selected {
|
||||
if _, ok := byKind[kind]; !ok {
|
||||
blocks := make([]OperatingDataBlock, len(plates))
|
||||
for i := range blocks {
|
||||
blocks[i] = operatingFailure(kind, true)
|
||||
}
|
||||
byKind[kind] = blocks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, plate := range plates {
|
||||
row := OperatingVehicleResult{VIN: vehicles[plate].VIN, PlateNumber: plate}
|
||||
succeeded := 0
|
||||
for kind, blocks := range byKind {
|
||||
block := blocks[i]
|
||||
if string(block["queryStatus"]) == `"SUCCESS"` {
|
||||
succeeded++
|
||||
}
|
||||
switch kind {
|
||||
case operatingRealtime:
|
||||
row.Realtime = block
|
||||
case operatingMileage:
|
||||
row.DailyMileage = block
|
||||
case operatingHydrogen:
|
||||
row.DailyHydrogen = block
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case succeeded == len(selected):
|
||||
result.Summary.SuccessCount++
|
||||
case succeeded == 0:
|
||||
result.Summary.FailedCount++
|
||||
default:
|
||||
result.Summary.PartialSuccessCount++
|
||||
}
|
||||
result.Data[i] = row
|
||||
}
|
||||
status := "success"
|
||||
if result.Summary.SuccessCount != len(plates) {
|
||||
status = "partial"
|
||||
if result.Summary.FailedCount == len(plates) {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
detail := ""
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
detail = "responseMode=SIMPLE"
|
||||
}
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", status, traceID, len(plates), detail)
|
||||
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func operatingFailure(kind string, timeout bool) OperatingDataBlock {
|
||||
prefix := map[string]string{operatingRealtime: "REALTIME", operatingMileage: "MILEAGE", operatingHydrogen: "HYDROGEN"}[kind]
|
||||
suffix := "_QUERY_FAILED"
|
||||
if timeout {
|
||||
suffix = "_QUERY_TIMEOUT"
|
||||
}
|
||||
code, _ := json.Marshal(prefix + suffix)
|
||||
return OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": code, "errorMessage": json.RawMessage(`"data query unavailable"`)}
|
||||
}
|
||||
|
||||
func operatingBlocks[T any](values []T, location *time.Location) []OperatingDataBlock {
|
||||
blocks := make([]OperatingDataBlock, len(values))
|
||||
for i, value := range values {
|
||||
block, err := operatingBlock(value, location)
|
||||
if err != nil {
|
||||
block = OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": json.RawMessage(`"SOURCE_DATA_INVALID"`), "errorMessage": json.RawMessage(`"source data cannot be serialized"`)}
|
||||
}
|
||||
blocks[i] = block
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func operatingBlock(value any, location *time.Location) (OperatingDataBlock, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var block OperatingDataBlock
|
||||
if err = json.Unmarshal(data, &block); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(block, "vin")
|
||||
delete(block, "plateNumber")
|
||||
var optional []string
|
||||
switch value.(type) {
|
||||
case RealtimeVehicleResult:
|
||||
optional = []string{"protocol", "recordTime", "socPercent", "timeDifferenceSeconds"}
|
||||
case MileageResult:
|
||||
optional = []string{"dataQuality"}
|
||||
case HydrogenResult:
|
||||
optional = []string{"calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason"}
|
||||
}
|
||||
for _, name := range optional {
|
||||
if _, exists := block[name]; !exists {
|
||||
block[name] = json.RawMessage("null")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"recordTime", "locationRecordTime", "hydrogenRecordTime", "dataTime", "updatedAt", "statisticsStartTime", "statisticsEndTime"} {
|
||||
raw, ok := block[name]
|
||||
if !ok || string(raw) == "null" {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if err = json.Unmarshal(raw, &text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at, parseErr := time.Parse(time.RFC3339Nano, text)
|
||||
if parseErr != nil {
|
||||
at, parseErr = time.ParseInLocation("2006-01-02 15:04:05", text, location)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
block[name], _ = json.Marshal(at.In(location).Format(time.RFC3339Nano))
|
||||
}
|
||||
block["queryStatus"] = json.RawMessage(`"SUCCESS"`)
|
||||
block["errorCode"] = json.RawMessage("null")
|
||||
block["errorMessage"] = json.RawMessage("null")
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (h *Handler) operatingData(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := newExternalTraceID()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
var request OperatingDataRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeOperatingDecodeError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeOperatingDecodeError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
result, err := h.service.QueryOperatingData(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
var limit *historicalHydrogenRateError
|
||||
var date *operatingDateError
|
||||
var batch *operatingBatchSizeError
|
||||
switch {
|
||||
case errors.As(err, &limit):
|
||||
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
|
||||
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMIT_EXCEEDED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
|
||||
case errors.As(err, &batch):
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "BATCH_SIZE_EXCEEDED", Message: "plateNumbers最多200项;查询全部授权车辆请省略plateNumbers或传空数组", TraceID: traceID})
|
||||
case errors.As(err, &date):
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: date.code, Message: "dailyDate无效或超出允许范围", TraceID: traceID})
|
||||
default:
|
||||
writeExternalError(w, traceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Large batches need no pretty-printing: keep the new endpoint compact while
|
||||
// preserving all existing endpoints' response formatting.
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
response := simplifyOperatingData(result, h.service.now().In(h.service.location))
|
||||
response.GeneratedAt = h.service.now().In(h.service.location).Format(time.RFC3339Nano)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func writeOperatingDecodeError(w http.ResponseWriter, traceID string, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeExternal(w, http.StatusRequestEntityTooLarge, ExternalResponse{Code: "PAYLOAD_TOO_LARGE", Message: "请求体超过64KiB", TraceID: traceID})
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求参数不正确", TraceID: traceID})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All nine keys are serialized, including unknown and unselected values as null.
|
||||
type OperatingSimpleVehicle struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
ReportTime *string `json:"reportTime"`
|
||||
Offline *bool `json:"offline"`
|
||||
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||
SOCPercent *float64 `json:"socPercent"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DailyHydrogenConsumptionKg *float64 `json:"dailyHydrogenConsumptionKg"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
HydrogenConsumptionPer100KmKg *float64 `json:"hydrogenConsumptionPer100KmKg"`
|
||||
}
|
||||
|
||||
// Explicit fields shadow the embedded FULL data and summary in JSON; all shared
|
||||
// envelope metadata remains identical. The original result is not mutated.
|
||||
type OperatingSimpleResponse struct {
|
||||
*OperatingDataResponse
|
||||
Data []OperatingSimpleVehicle `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
|
||||
func simplifyOperatingData(full *OperatingDataResponse, now time.Time) *OperatingSimpleResponse {
|
||||
metadata := *full
|
||||
metadata.Data = nil // Do not retain the large internal FULL payload during encoding.
|
||||
result := &OperatingSimpleResponse{OperatingDataResponse: &metadata, Data: make([]OperatingSimpleVehicle, len(full.Data)), Summary: OperatingDataSummary{RequestedCount: full.Summary.RequestedCount, ReturnedCount: len(full.Data)}}
|
||||
for i, source := range full.Data {
|
||||
row := OperatingSimpleVehicle{PlateNumber: source.PlateNumber}
|
||||
expected, available := 0, 0
|
||||
if source.Realtime != nil {
|
||||
expected += 5
|
||||
if simpleBlockNormal(source.Realtime) {
|
||||
row.ReportTime = simpleTime(source.Realtime, "recordTime")
|
||||
var online *bool
|
||||
if json.Unmarshal(source.Realtime["online"], &online) == nil && online != nil {
|
||||
offline := !*online
|
||||
row.Offline = &offline
|
||||
}
|
||||
row.SOCPercent = simpleNumber(source.Realtime, "socPercent", 100)
|
||||
row.TotalMileageKm = simpleNumber(source.Realtime, "totalMileageKm", math.MaxFloat64)
|
||||
if simpleText(source.Realtime, "remainingHydrogenKgStatus") == "NORMAL" {
|
||||
if at := simpleTime(source.Realtime, "hydrogenRecordTime"); at != nil {
|
||||
observed, _ := time.Parse(time.RFC3339Nano, *at)
|
||||
if !observed.After(now.Add(time.Minute)) && now.Sub(observed) <= time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
||||
row.RemainingHydrogenKg = simpleNumber(source.Realtime, "remainingHydrogenKg", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, valid := range []bool{row.ReportTime != nil, row.Offline != nil, row.RemainingHydrogenKg != nil, row.SOCPercent != nil, row.TotalMileageKm != nil} {
|
||||
if valid {
|
||||
available++
|
||||
}
|
||||
}
|
||||
}
|
||||
if source.DailyMileage != nil {
|
||||
expected++
|
||||
if simpleBlockNormal(source.DailyMileage) && simpleText(source.DailyMileage, "dataQuality") != "CARRIED_FORWARD" {
|
||||
row.DailyMileageKm = simpleNumber(source.DailyMileage, "dailyMileageKm", math.MaxFloat64)
|
||||
}
|
||||
if row.DailyMileageKm != nil {
|
||||
available++
|
||||
}
|
||||
}
|
||||
if source.DailyHydrogen != nil {
|
||||
expected++
|
||||
if simpleBlockNormal(source.DailyHydrogen) && simpleText(source.DailyHydrogen, "qualityStatus") == "OK" {
|
||||
row.DailyHydrogenConsumptionKg = simpleNumber(source.DailyHydrogen, "hydrogenConsumptionKg", math.MaxFloat64)
|
||||
}
|
||||
if row.DailyHydrogenConsumptionKg != nil {
|
||||
available++
|
||||
}
|
||||
}
|
||||
// No comparable-coverage proof is available from the independent projections.
|
||||
// Do not manufacture kg/100km by dividing same-day values. The reserved field
|
||||
// stays null and is excluded from availability counts until formally supported.
|
||||
switch {
|
||||
case available == 0:
|
||||
result.Summary.FailedCount++
|
||||
case available == expected:
|
||||
result.Summary.SuccessCount++
|
||||
default:
|
||||
result.Summary.PartialSuccessCount++
|
||||
}
|
||||
result.Data[i] = row
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func simpleBlockNormal(block OperatingDataBlock) bool {
|
||||
return simpleText(block, "queryStatus") == "SUCCESS" && simpleText(block, "status") == StatusNormal
|
||||
}
|
||||
func simpleText(block OperatingDataBlock, key string) string {
|
||||
var value string
|
||||
_ = json.Unmarshal(block[key], &value)
|
||||
return value
|
||||
}
|
||||
func simpleNumber(block OperatingDataBlock, key string, maximum float64) *float64 {
|
||||
var value *float64
|
||||
if json.Unmarshal(block[key], &value) != nil || value == nil || math.IsNaN(*value) || math.IsInf(*value, 0) || *value < 0 || *value > maximum {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
func simpleTime(block OperatingDataBlock, key string) *string {
|
||||
value := simpleText(block, key)
|
||||
at, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || at.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// The legacy FULL recordTime follows projection.updated_at, and its total
|
||||
// mileage historically coerces SQL NULL to zero. SIMPLE uses the actual event
|
||||
// time and nullable source evidence without changing the FULL contract.
|
||||
func prepareSimpleRealtime(values []RealtimeVehicleResult, plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, location *time.Location) {
|
||||
for i, plate := range plates {
|
||||
point, ok := points[vehicles[plate].VIN]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[i].RecordTime = ""
|
||||
if !point.LocationObservedAt.IsZero() {
|
||||
values[i].RecordTime = point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
|
||||
}
|
||||
if point.TotalMileageAvailable != nil && !*point.TotalMileageAvailable {
|
||||
values[i].TotalMileageKm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func simpleFixture(t testing.TB) (*OperatingDataResponse, time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
block := func(text string) OperatingDataBlock {
|
||||
var b OperatingDataBlock
|
||||
if err := json.Unmarshal([]byte(text), &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
return &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: "trace", QueryDate: stringPointer("2026-10-23"), Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), Summary: OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}, Data: []OperatingVehicleResult{{VIN: "PRIVATE_VIN", PlateNumber: "粤A12345F",
|
||||
Realtime: block(`{"queryStatus":"SUCCESS","status":"NORMAL","recordTime":"2026-10-23T11:59:59+08:00","online":false,"remainingHydrogenKg":0,"remainingHydrogenKgStatus":"NORMAL","hydrogenRecordTime":"2026-10-23T11:59:59+08:00","socPercent":0,"totalMileageKm":0}`),
|
||||
DailyMileage: block(`{"queryStatus":"SUCCESS","status":"NORMAL","dailyMileageKm":0,"dataQuality":null,"totalMileageKm":999}`),
|
||||
DailyHydrogen: block(`{"queryStatus":"SUCCESS","status":"NORMAL","qualityStatus":"OK","hydrogenConsumptionKg":0}`),
|
||||
}}}, now
|
||||
}
|
||||
func TestSimpleOperatingDataNineFieldsAndZeroValues(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
result := simplifyOperatingData(full, now)
|
||||
row := result.Data[0]
|
||||
if row.Offline == nil || !*row.Offline || row.ReportTime == nil {
|
||||
t.Fatal("report time/offline mapping lost")
|
||||
}
|
||||
for _, v := range []*float64{row.RemainingHydrogenKg, row.SOCPercent, row.TotalMileageKm, row.DailyHydrogenConsumptionKg, row.DailyMileageKm} {
|
||||
if v == nil || *v != 0 {
|
||||
t.Fatal("real zero dropped")
|
||||
}
|
||||
}
|
||||
if row.HydrogenConsumptionPer100KmKg != nil || result.Summary.SuccessCount != 1 {
|
||||
t.Fatal("reserved field affects display availability")
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := []string{"code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"}
|
||||
if len(encoded) != len(expected) {
|
||||
t.Fatalf("extra envelope fields: %s", data)
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, ok := encoded[key]; !ok {
|
||||
t.Fatalf("missing %s", key)
|
||||
}
|
||||
}
|
||||
var rows []map[string]json.RawMessage
|
||||
_ = json.Unmarshal(encoded["data"], &rows)
|
||||
keys := []string{"plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"}
|
||||
if len(rows[0]) != len(keys) {
|
||||
t.Fatalf("not exactly nine fields: %s", data)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, ok := rows[0][key]; !ok {
|
||||
t.Fatalf("missing simple key %s", key)
|
||||
}
|
||||
}
|
||||
if string(rows[0]["hydrogenConsumptionPer100KmKg"]) != "null" || strings.Contains(string(data), "PRIVATE_VIN") {
|
||||
t.Fatal("unproven derived value or internal identity exposed")
|
||||
}
|
||||
if !reflect.DeepEqual(full.Summary, OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}) {
|
||||
t.Fatal("FULL result mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataQualityAndPartialFailures(t *testing.T) {
|
||||
cases := []struct{ name, block, key, raw, field string }{
|
||||
{"stale hydrogen", "realtime", "remainingHydrogenKgStatus", `"STALE"`, "remainingHydrogenKg"},
|
||||
{"missing hydrogen", "realtime", "remainingHydrogenKgStatus", `"MISSING"`, "remainingHydrogenKg"},
|
||||
{"unsupported hydrogen", "realtime", "remainingHydrogenKgStatus", `"UNSUPPORTED"`, "remainingHydrogenKg"},
|
||||
{"expired hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T11:54:59+08:00"`, "remainingHydrogenKg"},
|
||||
{"future hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T12:01:01+08:00"`, "remainingHydrogenKg"},
|
||||
{"missing hydrogen time", "realtime", "hydrogenRecordTime", `null`, "remainingHydrogenKg"},
|
||||
{"negative mass", "realtime", "remainingHydrogenKg", `-1`, "remainingHydrogenKg"},
|
||||
{"invalid soc", "realtime", "socPercent", `101`, "socPercent"},
|
||||
{"missing realtime total", "realtime", "totalMileageKm", `null`, "totalMileageKm"},
|
||||
{"carried zero", "mileage", "dataQuality", `"CARRIED_FORWARD"`, "dailyMileageKm"},
|
||||
{"mileage anomaly", "mileage", "status", `"DATA_ANOMALY"`, "dailyMileageKm"},
|
||||
{"hydrogen anomaly", "hydrogen", "status", `"DATA_ANOMALY"`, "dailyHydrogenConsumptionKg"},
|
||||
{"hydrogen suspect", "hydrogen", "qualityStatus", `"SUSPECT"`, "dailyHydrogenConsumptionKg"},
|
||||
{"hydrogen failure", "hydrogen", "queryStatus", `"FAILED"`, "dailyHydrogenConsumptionKg"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
row := &full.Data[0]
|
||||
b := row.Realtime
|
||||
if tc.block == "mileage" {
|
||||
b = row.DailyMileage
|
||||
}
|
||||
if tc.block == "hydrogen" {
|
||||
b = row.DailyHydrogen
|
||||
}
|
||||
b[tc.key] = json.RawMessage(tc.raw)
|
||||
result := simplifyOperatingData(full, now)
|
||||
data, _ := json.Marshal(result.Data[0])
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &fields)
|
||||
if string(fields[tc.field]) != "null" || result.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatalf("unsafe value or summary: %s %+v", data, result.Summary)
|
||||
}
|
||||
// Unrelated known values survive a failure.
|
||||
if result.Data[0].ReportTime == nil || result.Data[0].Offline == nil {
|
||||
t.Fatal("unrelated display data erased")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataSelectionsAndNoData(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
full.Data[0].DailyMileage = nil
|
||||
full.Data[0].DailyHydrogen = nil
|
||||
full.QueryDate = nil
|
||||
result := simplifyOperatingData(full, now)
|
||||
if result.QueryDate != nil || result.Data[0].DailyMileageKm != nil || result.Data[0].DailyHydrogenConsumptionKg != nil || result.Summary.SuccessCount != 1 {
|
||||
t.Fatal("unselected fields affected counts")
|
||||
}
|
||||
full.Data[0].Realtime = operatingFailure(operatingRealtime, true)
|
||||
result = simplifyOperatingData(full, now)
|
||||
if result.Data[0].Offline != nil || result.Data[0].ReportTime != nil || result.Summary.FailedCount != 1 {
|
||||
t.Fatal("failure became offline or display success")
|
||||
}
|
||||
full.Data[0].Realtime = OperatingDataBlock{"queryStatus": json.RawMessage(`"SUCCESS"`), "status": json.RawMessage(`"NO_DATA"`), "online": json.RawMessage(`false`)}
|
||||
result = simplifyOperatingData(full, now)
|
||||
if result.Data[0].Offline != nil || result.Summary.FailedCount != 1 {
|
||||
t.Fatal("missing main record became offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataHTTPModeAndCompatibility(t *testing.T) {
|
||||
for _, mode := range []string{"", "FULL", "SIMPLE"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
f.query = func(_ context.Context, kind string) error {
|
||||
if kind == operatingHydrogen {
|
||||
return errors.New("upstream unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
body := map[string]any{"plateNumbers": []string{"粤A12345F"}, "dailyDate": "2026-10-23"}
|
||||
if mode != "" {
|
||||
body["responseMode"] = mode
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(string(raw)))
|
||||
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("HTTP=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var value struct {
|
||||
Data []map[string]json.RawMessage `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "SIMPLE" {
|
||||
if len(value.Data[0]) != 9 || value.Summary.FailedCount != 1 {
|
||||
t.Fatal("SIMPLE shape or availability wrong")
|
||||
}
|
||||
} else {
|
||||
if _, ok := value.Data[0]["realtime"]; !ok || value.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatal("FULL compatibility lost")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{`null`, `""`, `"simple"`, `"UNKNOWN"`, `[]`} {
|
||||
t.Run("invalid "+raw, func(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"responseMode":`+raw+`,"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`))
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("invalid mode HTTP=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSimpleOperatingData200Vehicles(b *testing.B) {
|
||||
full, now := simpleFixture(b)
|
||||
full.Data = make([]OperatingVehicleResult, 200)
|
||||
fixture, _ := simpleFixture(b)
|
||||
for i := range full.Data {
|
||||
full.Data[i] = fixture.Data[0]
|
||||
}
|
||||
full.Summary.RequestedCount = 200
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := json.Marshal(simplifyOperatingData(full, now)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleUsesSourceEventTimeAndNullableMileage(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
eventAt := now.Add(-10 * time.Minute)
|
||||
mock.ExpectQuery("l.soc_percent,l.total_mileage_km,l.updated_at").WithArgs(time.Date(2026, 10, 23, 0, 0, 0, 0, now.Location()), "vin", now.Add(-10*time.Minute)).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today", "event_time", "received_at", "event_id"}).AddRow("vin", "GB32960", 120, 30, 0, 0, nil, now, true, eventAt, now, "event"))
|
||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{"vin"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plates := []string{"粤A12345F"}
|
||||
vehicles := map[string]AuthorizedVehicle{plates[0]: {VIN: "vin", Plate: plates[0]}}
|
||||
full := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
|
||||
if full[0].TotalMileageKm == nil || *full[0].TotalMileageKm != 0 || full[0].RecordTime != now.Format("2006-01-02 15:04:05") {
|
||||
t.Fatal("FULL compatibility changed")
|
||||
}
|
||||
prepareSimpleRealtime(full, plates, vehicles, points, now.Location())
|
||||
if full[0].TotalMileageKm != nil || full[0].RecordTime != eventAt.Format(time.RFC3339Nano) {
|
||||
t.Fatal("SIMPLE used update time or fabricated zero")
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An absent event time cannot be replaced with received_at or updated_at.
|
||||
point := points["vin"]
|
||||
point.LocationObservedAt = time.Time{}
|
||||
available := true
|
||||
point.TotalMileageAvailable = &available
|
||||
points["vin"] = point
|
||||
values := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
|
||||
prepareSimpleRealtime(values, plates, vehicles, points, now.Location())
|
||||
if values[0].RecordTime != "" || values[0].TotalMileageKm == nil || *values[0].TotalMileageKm != 0 {
|
||||
t.Fatal("missing source time or real zero mishandled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const operatingTestKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
type operatingRepository struct {
|
||||
*fakeRepository
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
auth func(time.Time, time.Time) error
|
||||
scope func(time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||
query func(context.Context, string) error
|
||||
protocols []string
|
||||
}
|
||||
|
||||
func (f *operatingRepository) Authenticate(_ context.Context, _ [sha256.Size]byte, _ time.Time, start, end time.Time) (AppCredential, error) {
|
||||
f.mu.Lock()
|
||||
f.calls["auth"]++
|
||||
f.mu.Unlock()
|
||||
if f.auth != nil {
|
||||
if err := f.auth(start, end); err != nil {
|
||||
return AppCredential{}, err
|
||||
}
|
||||
}
|
||||
return f.app, nil
|
||||
}
|
||||
func (f *operatingRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
f.mu.Lock()
|
||||
f.calls["scope"]++
|
||||
f.mu.Unlock()
|
||||
if f.scope != nil {
|
||||
return f.scope(start, end)
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(f.vehicles)
|
||||
}
|
||||
found := make(map[string]AuthorizedVehicle, len(plates))
|
||||
for _, plate := range plates {
|
||||
if v, ok := f.vehicles[plate]; ok {
|
||||
found[plate] = v
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
func (f *operatingRepository) before(ctx context.Context, kind string) error {
|
||||
f.mu.Lock()
|
||||
f.calls[kind]++
|
||||
f.mu.Unlock()
|
||||
if f.query != nil {
|
||||
return f.query(ctx, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *operatingRepository) RealtimeVehicles(ctx context.Context, _ []string, _ time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
return f.realtime, f.before(ctx, operatingRealtime)
|
||||
}
|
||||
func (f *operatingRepository) DailyHydrogen(ctx context.Context, _ []string, _ string) (map[string]DailyHydrogen, error) {
|
||||
return f.hydrogen, f.before(ctx, operatingHydrogen)
|
||||
}
|
||||
func (f *operatingRepository) ReconciledMileageRange(ctx context.Context, _ []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.mu.Lock()
|
||||
f.protocols = append([]string(nil), protocols...)
|
||||
f.mu.Unlock()
|
||||
return f.mileage, f.before(ctx, operatingMileage)
|
||||
}
|
||||
func newOperatingFixture() (*Service, *operatingRepository) {
|
||||
f := &operatingRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345F": {VIN: "LTEST32960VIN0001", Plate: "粤A12345F"}, "粤B12345F": {VIN: "LTEST32960VIN0002", Plate: "粤B12345F"}}}, calls: map[string]int{}}
|
||||
s := NewService(f)
|
||||
s.now = func() time.Time { return time.Date(2026, 10, 23, 9, 30, 0, 0, s.location) }
|
||||
return s, f
|
||||
}
|
||||
func operatingRequest() OperatingDataRequest {
|
||||
return OperatingDataRequest{PlateNumbers: []string{"粤B12345F", " 粤a12345f "}, DailyDate: "2026-10-23"}
|
||||
}
|
||||
func blockText(b OperatingDataBlock, key string) string {
|
||||
var v string
|
||||
_ = json.Unmarshal(b[key], &v)
|
||||
return v
|
||||
}
|
||||
|
||||
func TestOperatingDataBatchesInParallelAndPreservesSemantics(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
now := s.now()
|
||||
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: now.Add(-time.Second), Online: true, SpeedKmh: 5, LiveHydrogen: RealtimeHydrogenData{RemainingHydrogenKg: float64Pointer(0), RemainingHydrogenKgStatus: "NORMAL", RemainingHydrogenPercentStatus: "MISSING", HydrogenDataStatus: "PARTIAL", HydrogenValueSource: stringPointer("REPORTED"), HydrogenRecordTime: stringPointer("2026-10-23T09:29:59+08:00")}}}
|
||||
f.hydrogen = map[string]DailyHydrogen{"LTEST32960VIN0001": {QualityStatus: "SUSPECT", QualityReason: "test anomaly", ConsumptionKg: 3, CalculationPhase: "PRELIMINARY", AlgorithmVersion: "test-v1"}}
|
||||
f.mileage = map[string]DailyMileage{dailyMileageKey("LTEST32960VIN0001", "2026-10-23"): {Date: "2026-10-22", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 200, DataTime: "2026-10-22T23:00:00+08:00", UpdatedAt: "2026-10-23T05:00:00+08:00", DataQuality: "CARRIED_FORWARD"}}
|
||||
started := make(chan string, 3)
|
||||
release := make(chan struct{})
|
||||
f.query = func(ctx context.Context, kind string) error {
|
||||
started <- kind
|
||||
select {
|
||||
case <-release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
done := make(chan *OperatingDataResponse, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
r, e := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest())
|
||||
done <- r
|
||||
errs <- e
|
||||
}()
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("batched queries did not start concurrently")
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
result := <-done
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Data) != 2 || result.Data[0].PlateNumber != "粤B12345F" || result.Summary.SuccessCount != 2 || result.QueryDate == nil {
|
||||
t.Fatalf("unexpected response: %+v", result)
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 1 {
|
||||
t.Fatalf("%s calls=%d", kind, f.calls[kind])
|
||||
}
|
||||
}
|
||||
if f.calls["auth"] != 2 || f.calls["scope"] != 2 {
|
||||
t.Fatalf("duplicate authorization reads: %v", f.calls)
|
||||
}
|
||||
row := result.Data[1]
|
||||
if string(row.Realtime["remainingHydrogenKg"]) != "0" || blockText(row.Realtime, "hydrogenDataStatus") != "PARTIAL" || blockText(row.Realtime, "recordTime") != "2026-10-23T09:29:59+08:00" {
|
||||
t.Fatalf("live quality/zero/time lost: %s", mustOperatingJSON(row.Realtime))
|
||||
}
|
||||
if blockText(row.DailyMileage, "dataQuality") != "CARRIED_FORWARD" || blockText(row.DailyMileage, "status") != "NORMAL" || string(row.DailyMileage["dailyMileageKm"]) != "0" {
|
||||
t.Fatal("carry-forward semantics changed")
|
||||
}
|
||||
if blockText(row.DailyHydrogen, "status") != "DATA_ANOMALY" || string(row.DailyHydrogen["hydrogenConsumptionKg"]) != "3" {
|
||||
t.Fatal("anomaly evidence lost")
|
||||
}
|
||||
if blockText(result.Data[0].Realtime, "status") != "NO_DATA" || string(result.Data[0].Realtime["socPercent"]) != "null" {
|
||||
t.Fatal("missing record not preserved")
|
||||
}
|
||||
// Compare with the independent endpoints through their original query services.
|
||||
f.query = nil
|
||||
old := NewService(f.fakeRepository)
|
||||
old.now = s.now
|
||||
plates := []string{"粤B12345F", "粤A12345F"}
|
||||
rt, err := old.QueryRealtimeVehicles(context.Background(), operatingTestKey, "old", RealtimeVehicleRequest{PlateNumbers: plates})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := old.QueryHydrogen(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := old.QueryMileage(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range rt {
|
||||
a, _ := operatingBlock(rt[i], s.location)
|
||||
b, _ := operatingBlock(h[i], s.location)
|
||||
c, _ := operatingBlock(m[i], s.location)
|
||||
if !reflect.DeepEqual(a, result.Data[i].Realtime) || !reflect.DeepEqual(b, result.Data[i].DailyHydrogen) || !reflect.DeepEqual(c, result.Data[i].DailyMileage) {
|
||||
t.Fatal("independent query mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
func mustOperatingJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
|
||||
|
||||
func TestOperatingDataAuthorizationFailsBeforeReadingData(t *testing.T) {
|
||||
for _, scenario := range []string{"invalid key", "current key", "history key", "current scope", "history scope", "identity conflict", "scope unavailable", "auth unavailable"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
key := operatingTestKey
|
||||
want := ErrForbidden
|
||||
if scenario == "invalid key" {
|
||||
key = "bad"
|
||||
want = ErrUnauthorized
|
||||
}
|
||||
f.auth = func(start, end time.Time) error {
|
||||
if scenario == "current key" && start.Equal(end) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if scenario == "history key" && !start.Equal(end) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if scenario == "auth unavailable" {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
if scenario == "current scope" && start.Equal(end) || scenario == "history scope" && !start.Equal(end) {
|
||||
return map[string]AuthorizedVehicle{}, nil
|
||||
}
|
||||
if scenario == "scope unavailable" {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
found := map[string]AuthorizedVehicle{}
|
||||
for k, v := range f.vehicles {
|
||||
found[k] = v
|
||||
}
|
||||
if scenario == "identity conflict" && !start.Equal(end) {
|
||||
found["粤A12345F"] = AuthorizedVehicle{VIN: "different"}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
_, err := s.QueryOperatingData(context.Background(), key, "trace", operatingRequest())
|
||||
switch scenario {
|
||||
case "current key":
|
||||
want = ErrUnauthorized
|
||||
case "auth unavailable", "scope unavailable", "identity conflict":
|
||||
want = nil
|
||||
}
|
||||
if err == nil || want != nil && !errors.Is(err, want) {
|
||||
t.Fatalf("err=%v want=%v", err, want)
|
||||
}
|
||||
if want == nil && (errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden)) {
|
||||
t.Fatal("infrastructure error hidden as denial")
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 0 {
|
||||
t.Fatal("queried data before all scopes passed")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataSelectionsAndFailures(t *testing.T) {
|
||||
for _, scenario := range []string{"only realtime", "only mileage", "hydrogen failure", "all failed", "invalid source"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := operatingRequest()
|
||||
switch scenario {
|
||||
case "only realtime":
|
||||
r.DataTypes = []string{operatingRealtime}
|
||||
r.DailyDate = ""
|
||||
case "only mileage":
|
||||
r.DataTypes = []string{operatingMileage}
|
||||
r.ProtocolPriority = ProtocolPriority{Present: true, Values: []string{"JT808"}}
|
||||
case "invalid source":
|
||||
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {ObservedAt: s.now(), LiveHydrogen: RealtimeHydrogenData{HydrogenDataStatus: "NORMAL", HydrogenRecordTime: stringPointer("bad timestamp")}}}
|
||||
}
|
||||
f.query = func(_ context.Context, kind string) error {
|
||||
if scenario == "all failed" || scenario == "hydrogen failure" && kind == operatingHydrogen {
|
||||
return errors.New("SQL secret should not escape")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch scenario {
|
||||
case "only realtime":
|
||||
if result.QueryDate != nil || result.Data[0].DailyMileage != nil || result.Data[0].DailyHydrogen != nil || f.calls["auth"] != 1 || f.calls["scope"] != 1 {
|
||||
t.Fatal("unselected work executed")
|
||||
}
|
||||
case "only mileage":
|
||||
if result.Data[0].Realtime != nil || f.calls["scope"] != 1 || !reflect.DeepEqual(f.protocols, []string{"JT808"}) {
|
||||
t.Fatalf("selection/protocol ignored: %v", f.protocols)
|
||||
}
|
||||
case "hydrogen failure":
|
||||
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_FAILED" || len(result.Data[0].DailyHydrogen) != 4 {
|
||||
t.Fatal("failure envelope incorrect")
|
||||
}
|
||||
case "all failed":
|
||||
if result.Summary.FailedCount != 2 {
|
||||
t.Fatal("all-failed count incorrect")
|
||||
}
|
||||
case "invalid source":
|
||||
if blockText(result.Data[1].Realtime, "errorCode") != "SOURCE_DATA_INVALID" || result.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatal("invalid source not isolated")
|
||||
}
|
||||
}
|
||||
if strings.Contains(mustOperatingJSON(result), "SQL secret") {
|
||||
t.Fatal("upstream details leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataDeadlineKeepsCompletedBlocks(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
exited := make(chan struct{})
|
||||
f.query = func(ctx context.Context, kind string) error {
|
||||
if kind == operatingHydrogen {
|
||||
<-ctx.Done()
|
||||
close(exited)
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
result, err := s.QueryOperatingData(ctx, operatingTestKey, "trace", operatingRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_TIMEOUT" {
|
||||
t.Fatalf("deadline discarded successful blocks: %+v", result.Summary)
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("query not canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataHTTPValidationAndRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`, 200},
|
||||
{`{"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"plateNumbers":[],"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"dataTypes":["REALTIME"]}`, 200},
|
||||
{`{"plateNumbers":[],"dataTypes":["REALTIME"],"responseMode":"SIMPLE"}`, 200},
|
||||
{`{"plateNumbers":null,"dataTypes":["REALTIME"]}`, 400},
|
||||
{`{}`, 400}, {`null`, 400}, {`[]`, 400}, {`{"plateNumbers":[]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"," 粤a12345f "],"dailyDate":"2026-10-23"}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-24"}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":[]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":["REALTIME","REALTIME"]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"],"protocolPriority":["GB32960"]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","protocolPriority":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","extra":1}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]} {}`, 400},
|
||||
{`{"plateNumbers":["` + strings.Repeat("x", 65<<10) + `"]}`, 413},
|
||||
}
|
||||
for i, tc := range tests {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(tc.body))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
if !IsPublicPath(OperatingDataQueryPath) {
|
||||
t.Fatal("new endpoint not registered as appKey route")
|
||||
}
|
||||
for _, construct := range []func(*Service) *Handler{NewHandler, func(s *Service) *Handler { return NewExternalHandler(s, nil) }} {
|
||||
s, _ := newOperatingFixture()
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
construct(s).ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("handler route missing: %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataLimitsAndBatchBoundary(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := OperatingDataRequest{DataTypes: []string{operatingRealtime}}
|
||||
for i := 0; i < 200; i++ {
|
||||
plate := fmt.Sprintf("TEST%03d", i)
|
||||
r.PlateNumbers = append(r.PlateNumbers, plate)
|
||||
f.vehicles[plate] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
// A repository returns only the authorized requested subset.
|
||||
f.scope = func(_, _ time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
v := map[string]AuthorizedVehicle{}
|
||||
for _, p := range r.PlateNumbers {
|
||||
v[p] = f.vehicles[p]
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if err != nil || len(result.Data) != 200 {
|
||||
t.Fatalf("boundary: %v", err)
|
||||
}
|
||||
r.PlateNumbers = append(r.PlateNumbers, "TEST201")
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatal("201 accepted")
|
||||
}
|
||||
s, f = newOperatingFixture()
|
||||
_ = f
|
||||
for i := 0; i < 30; i++ {
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 429 || w.Header().Get("Retry-After") == "" || !strings.Contains(w.Body.String(), "RATE_LIMIT_EXCEEDED") {
|
||||
t.Fatalf("limit: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Global capacity rejects work before any scope/data reads.
|
||||
s, f = newOperatingFixture()
|
||||
for i := 0; i < cap(s.operatingSlots); i++ {
|
||||
s.operatingSlots <- struct{}{}
|
||||
}
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err == nil || f.calls["scope"] != 0 {
|
||||
t.Fatal("global capacity ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOperatingData200Vehicles(b *testing.B) {
|
||||
s, f := newOperatingFixture()
|
||||
r := operatingRequest()
|
||||
r.PlateNumbers = nil
|
||||
f.vehicles = map[string]AuthorizedVehicle{}
|
||||
for i := 0; i < 200; i++ {
|
||||
p := fmt.Sprintf("TEST%03d", i)
|
||||
r.PlateNumbers = append(r.PlateNumbers, p)
|
||||
f.vehicles[p] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.operatingLimiter = historicalHydrogenLimiter{}
|
||||
f.audits = nil
|
||||
if _, err := s.QueryOperatingData(context.Background(), operatingTestKey, "bench", r); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingInstantGrantExpiresExclusively(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 10, 23, 10, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("AND (g.valid_to IS NULL OR g.valid_to>?)")).WithArgs(uint64(7), now, now, "粤A12345F").WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}))
|
||||
_, err = NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, []string{"粤A12345F"}, now, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataRetainsAllBusinessFields(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
for _, value := range []any{RealtimeVehicleResult{}, MileageResult{}, HydrogenResult{}} {
|
||||
block, err := operatingBlock(value, s.location)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var check func(reflect.Type)
|
||||
check = func(typ reflect.Type) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Anonymous {
|
||||
check(field.Type)
|
||||
continue
|
||||
}
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "vin" || name == "plateNumber" || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := block[name]; !ok {
|
||||
t.Errorf("%s missing %s", typ.Name(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
check(reflect.TypeOf(value))
|
||||
if _, exists := block["vin"]; exists {
|
||||
t.Fatal("duplicated identity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataGeneratesDistinctTraceIDs(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
handler := NewDataHandler(s)
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2; i++ {
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
r.Header.Set("X-Trace-Id", "reused-client-id")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
var result OperatingDataResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || result.TraceID == "reused-client-id" || len(result.TraceID) != 32 || seen[result.TraceID] {
|
||||
t.Fatalf("invalid trace: %s", w.Body.String())
|
||||
}
|
||||
seen[result.TraceID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataOptionalPlatesAuthorization(t *testing.T) {
|
||||
for _, scenario := range []string{"all", "realtime only", "history only", "intersection", "empty current", "empty history", "no grants", "identity conflict", "large all", "large before intersection"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := OperatingDataRequest{DailyDate: "2026-10-23"}
|
||||
if scenario == "realtime only" {
|
||||
r.DataTypes = []string{operatingRealtime}
|
||||
r.DailyDate = ""
|
||||
}
|
||||
if scenario == "history only" {
|
||||
r.DataTypes = []string{operatingMileage, operatingHydrogen}
|
||||
}
|
||||
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
found := map[string]AuthorizedVehicle{}
|
||||
for plate, v := range f.vehicles {
|
||||
found[plate] = v
|
||||
}
|
||||
current := start.Equal(end)
|
||||
if scenario == "no grants" || scenario == "empty current" && current || scenario == "empty history" && !current {
|
||||
return map[string]AuthorizedVehicle{}, nil
|
||||
}
|
||||
if scenario == "intersection" && !current {
|
||||
delete(found, "粤B12345F")
|
||||
found["NEW"] = AuthorizedVehicle{VIN: "OTHER"}
|
||||
}
|
||||
if scenario == "identity conflict" && !current {
|
||||
found["粤B12345F"] = AuthorizedVehicle{VIN: "CHANGED"}
|
||||
}
|
||||
if scenario == "large all" || scenario == "large before intersection" && current {
|
||||
for i := 0; i < 201; i++ {
|
||||
found[fmt.Sprintf("EXTRA%03d", i)] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if scenario == "identity conflict" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected authorization error, got %v", err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 2
|
||||
if scenario == "large all" {
|
||||
want = 203
|
||||
}
|
||||
if scenario == "intersection" {
|
||||
want = 1
|
||||
}
|
||||
if scenario == "empty current" || scenario == "empty history" || scenario == "no grants" {
|
||||
want = 0
|
||||
}
|
||||
if result.Data == nil || len(result.Data) != want || result.Summary.RequestedCount != want || result.Summary.ReturnedCount != want {
|
||||
t.Fatalf("incorrect authorized result: %+v", result)
|
||||
}
|
||||
if want > 0 && scenario != "large all" && result.Data[0].PlateNumber != "粤A12345F" {
|
||||
t.Fatal("default vehicle order is not stable")
|
||||
}
|
||||
}
|
||||
if err != nil || len(result.Data) == 0 {
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 0 {
|
||||
t.Fatal("business data read without an authorized batch")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingData1151AuthorizedVehiclesHTTP(t *testing.T) {
|
||||
for _, mode := range []string{"FULL", "SIMPLE"} {
|
||||
for _, empty := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/empty=%t", mode, empty), func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
f.vehicles = map[string]AuthorizedVehicle{}
|
||||
for i := 0; i < 1151; i++ {
|
||||
plate := fmt.Sprintf("TEST%04d", i)
|
||||
f.vehicles[plate] = AuthorizedVehicle{Plate: plate, VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
payload := map[string]any{"responseMode": mode, "dailyDate": "2026-10-23"}
|
||||
if empty {
|
||||
payload["plateNumbers"] = []string{}
|
||||
}
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(payload)))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
var result struct {
|
||||
Data []struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
} `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || len(result.Data) != 1151 || result.Summary.RequestedCount != 1151 || result.Summary.ReturnedCount != 1151 {
|
||||
t.Fatalf("status=%d rows=%d summary=%+v", w.Code, len(result.Data), result.Summary)
|
||||
}
|
||||
for i, row := range result.Data {
|
||||
if row.PlateNumber != fmt.Sprintf("TEST%04d", i) {
|
||||
t.Fatal("missing, duplicate or unordered authorized vehicle")
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 1 {
|
||||
t.Fatalf("%s not queried in one batch", kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataExplicitBatchSizeErrorHTTP(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
plates := make([]string, 201)
|
||||
for i := range plates {
|
||||
plates[i] = fmt.Sprintf("TEST%04d", i)
|
||||
}
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(map[string]any{"plateNumbers": plates, "dataTypes": []string{operatingRealtime}})))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
var response ExternalResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 400 || response.Code != "BATCH_SIZE_EXCEEDED" || !strings.Contains(response.Message, "200") || response.TraceID == "" {
|
||||
t.Fatalf("status=%d response=%+v", w.Code, response)
|
||||
}
|
||||
}
|
||||
@@ -282,18 +282,24 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildRealtimeVehicleResults(plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, now time.Time, location *time.Location) []RealtimeVehicleResult {
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
difference := int64(now.Sub(point.ObservedAt.In(location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||
applyRealtimeLiveData(&item, point, s.location)
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
applyRealtimeLiveData(&item, point, location)
|
||||
item.RecordTime = point.ObservedAt.In(location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
item.ActiveToday = point.ActiveToday
|
||||
@@ -315,8 +321,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
@@ -349,6 +354,8 @@ func validCoordinate(longitude, latitude float64) bool {
|
||||
|
||||
type Service struct {
|
||||
historicalLimiter historicalHydrogenLimiter
|
||||
operatingLimiter historicalHydrogenLimiter
|
||||
operatingSlots chan struct{}
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
@@ -362,9 +369,10 @@ type parsedGrant struct {
|
||||
|
||||
func NewService(repository Repository) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
repository: repository,
|
||||
operatingSlots: make(chan struct{}, 16),
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +395,12 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildHydrogenResults(plates, vehicles, values, date)
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildHydrogenResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyHydrogen, date string) []HydrogenResult {
|
||||
results := make([]HydrogenResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
@@ -414,8 +428,7 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
|
||||
@@ -441,6 +454,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildMileageResults(plates, vehicles, values, date)
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildMileageResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyMileage, date string) []MileageResult {
|
||||
results := make([]MileageResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
@@ -450,8 +469,7 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
|
||||
@@ -716,8 +734,8 @@ func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, s
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest)
|
||||
}
|
||||
end := endInclusive.AddDate(0, 0, 1)
|
||||
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest)
|
||||
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 1096 {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 1096 days", ErrInvalidRequest)
|
||||
}
|
||||
pageSize := request.PageSize
|
||||
if pageSize == 0 {
|
||||
|
||||
@@ -661,7 +661,7 @@ func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testin
|
||||
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, request := range []MileageRangeRequest{
|
||||
{StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100},
|
||||
{StartDate: "2023-07-01", EndDate: "2026-07-02", PageSize: 100},
|
||||
{StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100},
|
||||
{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001},
|
||||
} {
|
||||
@@ -801,3 +801,12 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
||||
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the already deployed three-year window when adding new public APIs.
|
||||
func TestMileageRangePreservesProductionThreeYearWindow(t *testing.T) {
|
||||
s := NewService(&fakeRepository{})
|
||||
_, _, _, _, _, _, _, err := s.validateMileageRange(MileageRangeRequest{StartDate: "2023-07-01", EndDate: "2026-06-30", PageSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("production 1096-day window rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/hydrogen"
|
||||
)
|
||||
|
||||
var hydrogenMassFields = []string{
|
||||
@@ -818,23 +820,7 @@ ORDER BY event_time ` + direction + ` LIMIT 1`
|
||||
}
|
||||
|
||||
func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) {
|
||||
temperatureK := temperatureC + 273.15
|
||||
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 {
|
||||
return 0, false
|
||||
}
|
||||
a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
|
||||
b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
|
||||
c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
|
||||
z := 1.0
|
||||
for index := range a {
|
||||
z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index])
|
||||
}
|
||||
if z <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000
|
||||
mass := density * capacityLiter / 1000
|
||||
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
|
||||
return hydrogen.PressureMassKg(pressureMPa, temperatureC, capacityLiter)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
|
||||
@@ -124,6 +124,9 @@ func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, in
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"}
|
||||
}
|
||||
input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if err := authorizeVehicleVIN(ctx, input.VIN); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if !validAccessClaimVIN(input.VIN) {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"}
|
||||
}
|
||||
@@ -230,6 +233,9 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
order := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
vin := strings.TrimSpace(item.VIN)
|
||||
if p, ok := PrincipalFromContext(ctx); ok && !p.CanVIN(vin) {
|
||||
continue
|
||||
}
|
||||
if _, exists := byVIN[vin]; !exists {
|
||||
order = append(order, vin)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ func (s *Service) alertStore() (alertStore, error) {
|
||||
}
|
||||
|
||||
func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
query.AllowedVINs = principalVehicleVINScope(ctx)
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertSummary{}, err
|
||||
@@ -52,6 +53,7 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
|
||||
query.AllowedVINs = principalVehicleVINScope(ctx)
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if strings.TrimSpace(query.Keyword) == "" {
|
||||
return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"}
|
||||
@@ -91,6 +93,9 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, event.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
@@ -404,10 +409,22 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
existing, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, existing.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
}
|
||||
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, event.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
@@ -497,7 +514,7 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
|
||||
default:
|
||||
return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() {
|
||||
query.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
@@ -515,7 +532,7 @@ 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" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() {
|
||||
request.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(request.AllowedVINs) == 0 {
|
||||
return 0, nil
|
||||
|
||||
@@ -106,6 +106,15 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
for _, item := range m.alertNotifications {
|
||||
if item.Channel == "in_app" && !item.Read {
|
||||
if query.AllowedVINs != nil {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
allowed = allowed || item.VehicleVIN == vin
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.UnreadNotifications++
|
||||
}
|
||||
}
|
||||
@@ -157,6 +166,15 @@ func (m *MockStore) AlertEvents(_ context.Context, query AlertQuery) (Page[Alert
|
||||
}
|
||||
|
||||
func keepMockAlertEvent(event AlertEvent, query AlertQuery) bool {
|
||||
if query.AllowedVINs != nil {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
allowed = allowed || event.VIN == vin
|
||||
}
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
keyword := strings.ToLower(query.Keyword)
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(event.VIN), keyword) && !strings.Contains(strings.ToLower(event.Plate), keyword) && !strings.Contains(strings.ToLower(event.RuleName), keyword) {
|
||||
return false
|
||||
|
||||
@@ -37,7 +37,15 @@ FROM vehicle_alert_event e WHERE `+where, args...).Scan(&result.Active, &result.
|
||||
if err != nil {
|
||||
return AlertSummary{}, err
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE channel='in_app' AND is_read=0`).Scan(&result.UnreadNotifications); err != nil {
|
||||
notificationWhere, notificationArgs := []string{"channel='in_app'", "is_read=0"}, []any{}
|
||||
if query.AllowedVINs != nil {
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
notificationWhere = append(notificationWhere, "1=0")
|
||||
} else {
|
||||
notificationWhere, notificationArgs = appendVINListFilter(notificationWhere, notificationArgs, "e.vin", strings.Join(query.AllowedVINs, ","))
|
||||
}
|
||||
}
|
||||
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 `+strings.Join(notificationWhere, " AND "), notificationArgs...).Scan(&result.UnreadNotifications); err != nil {
|
||||
return AlertSummary{}, err
|
||||
}
|
||||
result.AsOf = time.Now().Format(time.RFC3339)
|
||||
@@ -73,6 +81,13 @@ func (s *ProductionStore) ActiveAlertVINs(ctx context.Context, protocol string)
|
||||
func buildAlertWhere(query AlertQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.AllowedVINs != nil {
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
where = append(where, "1=0")
|
||||
} else {
|
||||
where, args = appendVINListFilter(where, args, "e.vin", strings.Join(query.AllowedVINs, ","))
|
||||
}
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(e.vin LIKE ? OR e.plate LIKE ? OR e.rule_name LIKE ?)")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CustomerHydrogenJob struct {
|
||||
Date string `json:"date"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
Attempts int `json:"attempts"`
|
||||
}
|
||||
type customerHydrogenJobStore interface {
|
||||
RequestCustomerHydrogenDays(context.Context, []string) ([]CustomerHydrogenJob, error)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RequestCustomerHydrogenDays(ctx context.Context, dates []string) ([]CustomerHydrogenJob, error) {
|
||||
values := make([]string, len(dates))
|
||||
args := make([]any, len(dates))
|
||||
for i, date := range dates {
|
||||
values[i] = "(?)"
|
||||
args[i] = date
|
||||
}
|
||||
if len(dates) == 0 {
|
||||
return []CustomerHydrogenJob{}, nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_hydrogen_job(stat_date) VALUES `+strings.Join(values, ","), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Recent dates can receive late telemetry. Completed older days remain cached;
|
||||
// current/recent days are recomputed at most once per hour, outside requests.
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE platform_customer_hydrogen_job SET status='pending',requested_at=NOW(3),attempts=0,error_message='' WHERE stat_date>=DATE_SUB(CURDATE(),INTERVAL 2 DAY) AND stat_date BETWEEN ? AND ? AND status='complete' AND completed_at<DATE_SUB(NOW(3),INTERVAL 1 HOUR)`, dates[0], dates[len(dates)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),status,COALESCE(DATE_FORMAT(completed_at,'%Y-%m-%dT%H:%i:%s+08:00'),''),attempts FROM platform_customer_hydrogen_job WHERE stat_date BETWEEN ? AND ? ORDER BY stat_date`, dates[0], dates[len(dates)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerHydrogenJob{}
|
||||
for rows.Next() {
|
||||
var j CustomerHydrogenJob
|
||||
if err = rows.Scan(&j.Date, &j.Status, &j.CompletedAt, &j.Attempts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, j)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestCustomerHydrogenRefreshResetsRetryBudget(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec("INSERT IGNORE INTO platform_customer_hydrogen_job").WithArgs("2026-09-29").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
// Only completed recent jobs may be refreshed. A new refresh gets its own
|
||||
// retry budget instead of inheriting all previous successful executions.
|
||||
mock.ExpectExec("UPDATE platform_customer_hydrogen_job SET .*attempts=0.*status='complete'.*INTERVAL 1 HOUR").WithArgs("2026-09-29", "2026-09-29").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FROM platform_customer_hydrogen_job").WithArgs("2026-09-29", "2026-09-29").WillReturnRows(sqlmock.NewRows([]string{"date", "status", "completed", "attempts"}).AddRow("2026-09-29", "pending", "2026-09-30T03:00:00+08:00", 0))
|
||||
jobs, err := (&ProductionStore{db: db}).RequestCustomerHydrogenDays(context.Background(), []string{"2026-09-29"})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Status != "pending" {
|
||||
t.Fatalf("refresh result: %+v, %v", jobs, err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CustomerMileageRules apply to each vehicle-day before customer aggregation.
|
||||
// Comparing customer totals would allow opposite vehicle discrepancies to cancel.
|
||||
type CustomerMileageRules struct {
|
||||
Version int64 `json:"version"`
|
||||
LowMileageKm float64 `json:"lowMileageKm"`
|
||||
TargetMileageKm float64 `json:"targetMileageKm"`
|
||||
DeviationKm float64 `json:"deviationKm"`
|
||||
DeviationPercent float64 `json:"deviationPercent"`
|
||||
MinimumGPSKm float64 `json:"minimumGpsKm"`
|
||||
IncludeToday bool `json:"includeToday"`
|
||||
}
|
||||
|
||||
func DefaultCustomerMileageRules() CustomerMileageRules {
|
||||
return CustomerMileageRules{Version: 1, LowMileageKm: 50, TargetMileageKm: 200, DeviationKm: 30, DeviationPercent: 20, MinimumGPSKm: 50}
|
||||
}
|
||||
|
||||
func (r CustomerMileageRules) Validate() error {
|
||||
if r.Version < 0 {
|
||||
return fmt.Errorf("规则版本无效")
|
||||
}
|
||||
for _, value := range []float64{r.LowMileageKm, r.TargetMileageKm, r.DeviationKm, r.DeviationPercent, r.MinimumGPSKm} {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return fmt.Errorf("预警阈值必须为非负有限数值")
|
||||
}
|
||||
}
|
||||
if r.TargetMileageKm <= r.LowMileageKm || r.TargetMileageKm > 3000 {
|
||||
return fmt.Errorf("达标里程必须大于低里程阈值且不超过 3000 km")
|
||||
}
|
||||
if r.DeviationKm <= 0 || r.DeviationKm > 3000 || r.DeviationPercent <= 0 || r.DeviationPercent > 100 || r.MinimumGPSKm > 3000 {
|
||||
return fmt.Errorf("偏差阈值应为 0–3000 km、0–100%%(不含零),最低 GPS 里程不超过 3000 km")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CustomerVehicleAssignment struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Customer string `json:"customer"`
|
||||
Department string `json:"department"`
|
||||
ResponsiblePerson string `json:"responsiblePerson"`
|
||||
}
|
||||
|
||||
type CustomerVehicleDay struct {
|
||||
Restricted bool `json:"restricted"`
|
||||
HydrogenCoveragePercent *float64 `json:"hydrogenCoveragePercent"`
|
||||
HydrogenObservedKm *float64 `json:"hydrogenObservedKm"`
|
||||
HydrogenSource string `json:"hydrogenSource,omitempty"`
|
||||
CustomerVehicleAssignment
|
||||
Date string `json:"date"`
|
||||
GPSKm *float64 `json:"gpsKm"`
|
||||
HydrogenKm *float64 `json:"hydrogenKm"`
|
||||
GPSStatus string `json:"gpsStatus"`
|
||||
HydrogenStatus string `json:"hydrogenStatus"`
|
||||
DifferenceKm *float64 `json:"differenceKm"`
|
||||
DifferencePercent *float64 `json:"differencePercent"`
|
||||
Alert string `json:"alert"`
|
||||
Provisional bool `json:"provisional"`
|
||||
QualityReason string `json:"qualityReason,omitempty"`
|
||||
}
|
||||
|
||||
func validCustomerMileage(v *float64) bool {
|
||||
return v != nil && !math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0
|
||||
}
|
||||
|
||||
func customerMileageBand(value *float64, rules CustomerMileageRules) string {
|
||||
if !validCustomerMileage(value) {
|
||||
return "missing"
|
||||
}
|
||||
if *value < rules.LowMileageKm {
|
||||
return "low"
|
||||
}
|
||||
if *value < rules.TargetMileageKm {
|
||||
return "below-target"
|
||||
}
|
||||
return "on-target"
|
||||
}
|
||||
|
||||
func evaluateCustomerVehicleDay(day CustomerVehicleDay, rules CustomerMileageRules) CustomerVehicleDay {
|
||||
if day.Restricted {
|
||||
day.GPSKm = nil
|
||||
day.HydrogenKm = nil
|
||||
day.GPSStatus = "restricted"
|
||||
day.HydrogenStatus = "restricted"
|
||||
day.DifferenceKm = nil
|
||||
day.DifferencePercent = nil
|
||||
day.Alert = "restricted"
|
||||
return day
|
||||
}
|
||||
day.GPSStatus = customerMileageBand(day.GPSKm, rules)
|
||||
day.HydrogenStatus = customerMileageBand(day.HydrogenKm, rules)
|
||||
day.DifferenceKm, day.DifferencePercent = nil, nil
|
||||
day.Alert = "normal"
|
||||
if day.GPSStatus == "missing" || day.HydrogenStatus == "missing" {
|
||||
day.Alert = "missing-data"
|
||||
return day
|
||||
}
|
||||
difference := *day.GPSKm - *day.HydrogenKm
|
||||
day.DifferenceKm = &difference
|
||||
if *day.GPSKm > 0 {
|
||||
percent := difference / *day.GPSKm * 100
|
||||
day.DifferencePercent = &percent
|
||||
}
|
||||
if strings.TrimSpace(day.QualityReason) != "" {
|
||||
day.Alert = "data-quality"
|
||||
return day
|
||||
}
|
||||
if day.Provisional && !rules.IncludeToday {
|
||||
day.Alert = "provisional"
|
||||
return day
|
||||
}
|
||||
if difference < -rules.DeviationKm {
|
||||
day.Alert = "reverse-deviation"
|
||||
return day
|
||||
}
|
||||
if *day.GPSKm >= rules.MinimumGPSKm && difference >= rules.DeviationKm && day.DifferencePercent != nil && *day.DifferencePercent >= rules.DeviationPercent {
|
||||
day.Alert = "suspected-charging"
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// CustomerMileageObservation holds independently selected GPS and hydrogen
|
||||
// measurements. A missing source is nil, never an artificial zero.
|
||||
type CustomerMileageObservation struct {
|
||||
Restricted bool
|
||||
HydrogenCoveragePercent *float64
|
||||
HydrogenObservedKm *float64
|
||||
HydrogenSource string
|
||||
VIN string
|
||||
Date string
|
||||
GPSKm *float64
|
||||
HydrogenKm *float64
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type CustomerMileageDay struct {
|
||||
Date string `json:"date"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
GPSVehicleCount int `json:"gpsVehicleCount"`
|
||||
HydrogenVehicleCount int `json:"hydrogenVehicleCount"`
|
||||
GPSKm *float64 `json:"gpsKm"`
|
||||
HydrogenKm *float64 `json:"hydrogenKm"`
|
||||
SuspectedChargingCount int `json:"suspectedChargingCount"`
|
||||
DataIssueCount int `json:"dataIssueCount"`
|
||||
LowMileageCount int `json:"lowMileageCount"`
|
||||
Provisional bool `json:"provisional"`
|
||||
}
|
||||
|
||||
type CustomerMileageGroup struct {
|
||||
Customer string `json:"customer"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
Departments []string `json:"departments"`
|
||||
ResponsiblePeople []string `json:"responsiblePeople"`
|
||||
Days []CustomerMileageDay `json:"days"`
|
||||
Vehicles []CustomerVehicleDay `json:"vehicles"`
|
||||
}
|
||||
|
||||
// aggregateCustomerMileage receives authorized assignments only. It excludes
|
||||
// unassigned VINs and refuses conflicting current assignments instead of placing
|
||||
// the same vehicle under multiple customers. Caller must scope measurements too.
|
||||
func aggregateCustomerMileage(assignments []CustomerVehicleAssignment, observations []CustomerMileageObservation, dates []string, today string, rules CustomerMileageRules) ([]CustomerMileageGroup, error) {
|
||||
if err := rules.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byVIN := map[string]CustomerVehicleAssignment{}
|
||||
for _, a := range assignments {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
a.Customer = strings.TrimSpace(a.Customer)
|
||||
a.Department = strings.TrimSpace(a.Department)
|
||||
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
|
||||
if a.VIN == "" || a.Customer == "" {
|
||||
continue
|
||||
}
|
||||
if prev, ok := byVIN[a.VIN]; ok && prev != a {
|
||||
return nil, fmt.Errorf("车辆客户归属存在冲突,请刷新 OneOS 车辆数据")
|
||||
}
|
||||
byVIN[a.VIN] = a
|
||||
}
|
||||
type key struct{ vin, date string }
|
||||
measured := map[key]CustomerMileageObservation{}
|
||||
for _, o := range observations {
|
||||
o.VIN = strings.ToUpper(strings.TrimSpace(o.VIN))
|
||||
if _, ok := byVIN[o.VIN]; !ok {
|
||||
continue
|
||||
}
|
||||
k := key{o.VIN, o.Date}
|
||||
if _, ok := measured[k]; ok {
|
||||
return nil, fmt.Errorf("同一车辆日期存在重复里程结果")
|
||||
}
|
||||
measured[k] = o
|
||||
}
|
||||
groups := map[string]*CustomerMileageGroup{}
|
||||
vins := make([]string, 0, len(byVIN))
|
||||
for vin := range byVIN {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
for _, vin := range vins {
|
||||
a := byVIN[vin]
|
||||
g := groups[a.Customer]
|
||||
if g == nil {
|
||||
g = &CustomerMileageGroup{Customer: a.Customer, Departments: []string{}, ResponsiblePeople: []string{}, Days: make([]CustomerMileageDay, len(dates)), Vehicles: []CustomerVehicleDay{}}
|
||||
groups[a.Customer] = g
|
||||
for i, date := range dates {
|
||||
g.Days[i] = CustomerMileageDay{Date: date, Provisional: date == today}
|
||||
}
|
||||
}
|
||||
g.VehicleCount++
|
||||
if a.Department != "" {
|
||||
g.Departments = appendUniqueCustomerValue(g.Departments, a.Department)
|
||||
}
|
||||
if a.ResponsiblePerson != "" {
|
||||
g.ResponsiblePeople = appendUniqueCustomerValue(g.ResponsiblePeople, a.ResponsiblePerson)
|
||||
}
|
||||
for i, date := range dates {
|
||||
o := measured[key{vin, date}]
|
||||
d := evaluateCustomerVehicleDay(CustomerVehicleDay{CustomerVehicleAssignment: a, Date: date, GPSKm: o.GPSKm, HydrogenKm: o.HydrogenKm, Restricted: o.Restricted, HydrogenCoveragePercent: o.HydrogenCoveragePercent, HydrogenObservedKm: o.HydrogenObservedKm, HydrogenSource: o.HydrogenSource, QualityReason: o.QualityReason, Provisional: date == today}, rules)
|
||||
g.Vehicles = append(g.Vehicles, d)
|
||||
day := &g.Days[i]
|
||||
if !d.Restricted {
|
||||
day.VehicleCount++
|
||||
}
|
||||
if validCustomerMileage(d.GPSKm) {
|
||||
addCustomerMileage(&day.GPSKm, *d.GPSKm)
|
||||
day.GPSVehicleCount++
|
||||
}
|
||||
if validCustomerMileage(d.HydrogenKm) {
|
||||
addCustomerMileage(&day.HydrogenKm, *d.HydrogenKm)
|
||||
day.HydrogenVehicleCount++
|
||||
}
|
||||
if d.Alert == "suspected-charging" {
|
||||
day.SuspectedChargingCount++
|
||||
}
|
||||
if d.Alert == "missing-data" || d.Alert == "data-quality" || d.Alert == "reverse-deviation" {
|
||||
day.DataIssueCount++
|
||||
}
|
||||
if d.GPSStatus == "low" && (!d.Provisional || rules.IncludeToday) {
|
||||
day.LowMileageCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]CustomerMileageGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
sort.Strings(g.Departments)
|
||||
sort.Strings(g.ResponsiblePeople)
|
||||
result = append(result, *g)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Customer < result[j].Customer })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func appendUniqueCustomerValue(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
func addCustomerMileage(total **float64, value float64) {
|
||||
if *total == nil {
|
||||
*total = new(float64)
|
||||
}
|
||||
**total += value
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) handleCustomerMileage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := h.service.CustomerMileage(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
func (h *Handler) handleCustomerMileageRules(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodGet {
|
||||
data, err := h.service.CustomerMileageRules(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
return
|
||||
}
|
||||
var rules CustomerMileageRules
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&rules); err != nil {
|
||||
h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "预警规则格式无效"})
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "请求只能包含一份规则"})
|
||||
return
|
||||
}
|
||||
data, err := h.service.SaveCustomerMileageRules(r.Context(), rules)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomerAssignmentSnapshot struct {
|
||||
Rows []CustomerVehicleAssignment
|
||||
ObservedAt time.Time
|
||||
Tenant string
|
||||
Live bool
|
||||
}
|
||||
type customerAssignmentContextKey struct{}
|
||||
|
||||
func WithCustomerAssignments(ctx context.Context, snapshot CustomerAssignmentSnapshot) context.Context {
|
||||
return context.WithValue(ctx, customerAssignmentContextKey{}, snapshot)
|
||||
}
|
||||
|
||||
type CustomerMileageResponse struct {
|
||||
HydrogenJobs []CustomerHydrogenJob `json:"hydrogenJobs"`
|
||||
Groups []CustomerMileageGroup `json:"groups"`
|
||||
Rules CustomerMileageRules `json:"rules"`
|
||||
Dates []string `json:"dates"`
|
||||
AssignmentSource string `json:"assignmentSource"`
|
||||
AssignmentObservedAt string `json:"assignmentObservedAt"`
|
||||
AssignmentStale bool `json:"assignmentStale"`
|
||||
AssignmentMissingCount int `json:"assignmentMissingCount"`
|
||||
ExcludedUnassignedCount int `json:"excludedUnassignedCount"`
|
||||
CanConfigure bool `json:"canConfigure"`
|
||||
}
|
||||
|
||||
func customerMileageAccess(ctx context.Context) (Principal, error) {
|
||||
p, ok := PrincipalFromContext(ctx)
|
||||
if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu("customer-mileage") {
|
||||
return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看客户运营里程"}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func customerMileageScope(ctx context.Context, p Principal) string {
|
||||
tenant := p.TenantRef
|
||||
if snapshot, ok := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot); ok {
|
||||
tenant = snapshot.Tenant
|
||||
}
|
||||
return "tenant:" + tenant
|
||||
}
|
||||
func (s *Service) customerMileageStore() (CustomerMileageStore, error) {
|
||||
store, ok := s.store.(CustomerMileageStore)
|
||||
if !ok {
|
||||
return nil, clientError{Code: "CUSTOMER_MILEAGE_UNAVAILABLE", Message: "当前数据源尚未启用客户运营里程"}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
func (s *Service) CustomerMileageRules(ctx context.Context) (CustomerMileageRules, error) {
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
return store.CustomerMileageRules(ctx, customerMileageScope(ctx, p))
|
||||
}
|
||||
func (s *Service) SaveCustomerMileageRules(ctx context.Context, r CustomerMileageRules) (CustomerMileageRules, error) {
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if p.Role != "admin" || p.ReadOnly {
|
||||
return r, clientError{Code: "PERMISSION_DENIED", Message: "仅管理员可修改客户运营里程规则"}
|
||||
}
|
||||
if err = r.Validate(); err != nil {
|
||||
return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()}
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
return store.SaveCustomerMileageRules(ctx, customerMileageScope(ctx, p), r, ActorFromContext(ctx))
|
||||
}
|
||||
|
||||
func customerMileageDates(q url.Values, now time.Time) ([]string, error) {
|
||||
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
today := now.In(zone).Format("2006-01-02")
|
||||
from, to := q.Get("dateFrom"), q.Get("dateTo")
|
||||
if to == "" {
|
||||
to = now.In(zone).AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02", to, zone)
|
||||
if err != nil {
|
||||
return nil, clientError{Code: "INVALID_DATE", Message: "结束日期格式应为 YYYY-MM-DD"}
|
||||
}
|
||||
if from == "" {
|
||||
from = end.AddDate(0, 0, -6).Format("2006-01-02")
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", from, zone)
|
||||
if err != nil || start.After(end) || to > today || end.Sub(start) > 365*24*time.Hour {
|
||||
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "请选择不晚于今天、最多 366 天的有效日期范围"}
|
||||
}
|
||||
dates := []string{}
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
dates = append(dates, d.Format("2006-01-02"))
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func (s *Service) CustomerMileage(ctx context.Context, q url.Values) (CustomerMileageResponse, error) {
|
||||
response := CustomerMileageResponse{Groups: []CustomerMileageGroup{}}
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
dates, err := customerMileageDates(q, time.Now())
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Dates = dates
|
||||
snapshot, live := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot)
|
||||
live = live && snapshot.Live
|
||||
tenant := p.TenantRef
|
||||
if snapshot.Tenant != "" {
|
||||
tenant = snapshot.Tenant
|
||||
}
|
||||
if live {
|
||||
snapshot.Rows, err = normalizeCustomerAssignments(snapshot.Rows)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.AssignmentSource = "oneos-live"
|
||||
// Persist only a complete, authenticated upstream snapshot. Never use client
|
||||
// submitted metadata or another session's credentials to refresh it.
|
||||
if err = store.SaveCustomerAssignments(ctx, tenant, snapshot.Rows, snapshot.ObservedAt); err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
snapshot.Rows, snapshot.ObservedAt, err = store.CustomerAssignments(ctx, tenant)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.AssignmentSource = "oneos-synced"
|
||||
}
|
||||
response.AssignmentStale = snapshot.ObservedAt.IsZero() || time.Since(snapshot.ObservedAt) > 24*time.Hour
|
||||
if !snapshot.ObservedAt.IsZero() {
|
||||
response.AssignmentObservedAt = snapshot.ObservedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
response.CanConfigure = p.Role == "admin" && !p.ReadOnly
|
||||
response.Rules, err = store.CustomerMileageRules(ctx, customerMileageScope(ctx, p))
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
selected := []CustomerVehicleAssignment{}
|
||||
vins := []string{}
|
||||
unassigned := map[string]bool{}
|
||||
knownAssignments := map[string]bool{}
|
||||
for _, a := range snapshot.Rows {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
if !isLikelyVIN(a.VIN) || !p.CanVIN(a.VIN) {
|
||||
continue
|
||||
}
|
||||
knownAssignments[a.VIN] = true
|
||||
if strings.TrimSpace(a.Customer) == "" {
|
||||
unassigned[a.VIN] = true
|
||||
continue
|
||||
}
|
||||
if q.Get("customer") != "" && !strings.Contains(strings.ToLower(a.Customer), strings.ToLower(q.Get("customer"))) {
|
||||
continue
|
||||
}
|
||||
if q.Get("department") != "" && !strings.Contains(strings.ToLower(a.Department), strings.ToLower(strings.TrimSpace(q.Get("department")))) {
|
||||
continue
|
||||
}
|
||||
if q.Get("responsiblePerson") != "" && !strings.Contains(strings.ToLower(a.ResponsiblePerson), strings.ToLower(strings.TrimSpace(q.Get("responsiblePerson")))) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, a)
|
||||
vins = append(vins, a.VIN)
|
||||
}
|
||||
response.ExcludedUnassignedCount = len(unassigned)
|
||||
if !live && p.HasVehicleScope() {
|
||||
for _, vin := range p.VehicleVINs {
|
||||
if p.CanVIN(vin) && !knownAssignments[vin] {
|
||||
response.AssignmentMissingCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
if len(vins)*len(dates) > 100000 {
|
||||
return response, clientError{Code: "CUSTOMER_MILEAGE_RANGE_TOO_LARGE", Message: "本次查询超过 100,000 个车辆日,请缩短日期或筛选客户、部门"}
|
||||
}
|
||||
// Construct a new query to discard all user-supplied authorization fields.
|
||||
query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "vins": {strings.Join(vins, ",")}}
|
||||
query, err = applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err = requirePrincipalHistoricalGrantScope(ctx, query); err != nil {
|
||||
return response, err
|
||||
}
|
||||
observations, err := store.CustomerMileageObservations(ctx, query)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if p.HasHistoryTimeScope() {
|
||||
starts := map[string]string{}
|
||||
if err = json.Unmarshal([]byte(query.Get("scopeVehicleFrom")), &starts); err != nil {
|
||||
return response, err
|
||||
}
|
||||
for _, a := range selected {
|
||||
for _, date := range dates {
|
||||
if date < starts[a.VIN] {
|
||||
observations = append(observations, CustomerMileageObservation{VIN: a.VIN, Date: date, Restricted: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response.Groups, err = aggregateCustomerMileage(selected, observations, dates, time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02"), response.Rules)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func normalizeCustomerAssignments(rows []CustomerVehicleAssignment) ([]CustomerVehicleAssignment, error) {
|
||||
result := []CustomerVehicleAssignment{}
|
||||
seen := map[string]CustomerVehicleAssignment{}
|
||||
for _, a := range rows {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
a.Plate = strings.TrimSpace(a.Plate)
|
||||
a.Customer = strings.TrimSpace(a.Customer)
|
||||
a.Department = strings.TrimSpace(a.Department)
|
||||
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
|
||||
if !isLikelyVIN(a.VIN) {
|
||||
continue
|
||||
}
|
||||
if old, ok := seen[a.VIN]; ok {
|
||||
if old != a {
|
||||
return nil, clientError{Code: "CUSTOMER_ASSIGNMENT_CONFLICT", Message: "OneOS 同一 VIN 存在冲突归属,请先核查车辆目录"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[a.VIN] = a
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type customerMileageTestStore struct {
|
||||
Store
|
||||
assignments []CustomerVehicleAssignment
|
||||
query url.Values
|
||||
saved int
|
||||
rules CustomerMileageRules
|
||||
}
|
||||
|
||||
func (s *customerMileageTestStore) CustomerMileageRules(context.Context, string) (CustomerMileageRules, error) {
|
||||
return s.rules, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) SaveCustomerMileageRules(_ context.Context, _ string, r CustomerMileageRules, _ string) (CustomerMileageRules, error) {
|
||||
s.saved++
|
||||
s.rules = r
|
||||
return r, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) CustomerMileageObservations(_ context.Context, q url.Values) ([]CustomerMileageObservation, error) {
|
||||
s.query = q
|
||||
return []CustomerMileageObservation{}, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *customerMileageTestStore) CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
return s.assignments, time.Now(), nil
|
||||
}
|
||||
|
||||
func TestCustomerMileageServiceScopesAssignmentsAndHistory(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
other := "LNX00000000000002"
|
||||
store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: other, Customer: "乙"}}}
|
||||
service := NewService(store)
|
||||
from := time.Date(2026, 1, 10, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: from}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
result, err := service.CustomerMileage(ctx, url.Values{"dateFrom": {"2026-01-01"}, "dateTo": {"2026-01-31"}, "scopeVins": {other}, "scopeVehicleFrom": {"{}"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Groups) != 1 || result.Groups[0].Customer != "甲" {
|
||||
t.Fatalf("unauthorized assignment exposed: %+v", result.Groups)
|
||||
}
|
||||
if store.query.Get("vins") != vin || store.query.Get("scopeVins") != vin || !strings.Contains(store.query.Get("scopeVehicleFrom"), "2026-01-11") {
|
||||
t.Fatalf("lost history scope: %v", store.query)
|
||||
}
|
||||
if _, err = service.SaveCustomerMileageRules(ctx, store.rules); err == nil || store.saved != 0 {
|
||||
t.Fatal("customer changed shared rules")
|
||||
}
|
||||
p.MenuKeys = []string{"statistics"}
|
||||
ctx = WithPrincipal(context.Background(), p)
|
||||
if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil {
|
||||
t.Fatal("statistics permission implicitly exposed hydrogen")
|
||||
}
|
||||
p.MenuKeys = []string{"customer-mileage"}
|
||||
p.VehicleGrants = nil
|
||||
ctx = WithPrincipal(context.Background(), p)
|
||||
if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil {
|
||||
t.Fatal("native historical grant bypassed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageDateRangeValidation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 30, 1, 0, 0, 0, time.UTC)
|
||||
dates, err := customerMileageDates(url.Values{}, now)
|
||||
if err != nil || len(dates) != 7 || dates[0] != "2026-09-23" || dates[6] != "2026-09-29" {
|
||||
t.Fatalf("bad default: %v %v", dates, err)
|
||||
}
|
||||
for _, q := range []url.Values{{"dateFrom": {"2026-10-01"}}, {"dateTo": {"2026-09-31"}}, {"dateFrom": {"2020-01-01"}}, {"dateFrom": {"2026-09-30"}, "dateTo": {"2026-09-29"}}} {
|
||||
if _, err = customerMileageDates(q, now); err == nil {
|
||||
t.Fatalf("bad dates accepted: %v", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageSQLPreservesScopeAndIndependentSources(t *testing.T) {
|
||||
q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "vins": {"LNX00000000000001"}, "scopeVins": {"LNX00000000000001"}, "scopeVehicleFrom": {`{"LNX00000000000001":"2026-09-10"}`}}
|
||||
sql := buildCustomerMileageSQL(q)
|
||||
for _, want := range []string{"m.protocol = 'JT808'", "m.vin IN", "m.stat_date >="} {
|
||||
if !strings.Contains(sql.Text, want) {
|
||||
t.Fatalf("missing %s in query", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql.Text, "LNX00000000000001") {
|
||||
t.Fatal("VIN interpolated into SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAssignmentConflictPreventsPersistence(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
if _, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: vin, Customer: ""}}); err == nil {
|
||||
t.Fatal("assigned/unassigned conflict accepted")
|
||||
}
|
||||
got, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: " 甲 "}, {VIN: vin, Customer: "甲"}})
|
||||
if err != nil || len(got) != 1 || got[0].Customer != "甲" {
|
||||
t.Fatalf("normalized duplicates not collapsed: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageHistoricalRestrictionsAreNotMissingDataAlerts(t *testing.T) {
|
||||
rules := DefaultCustomerMileageRules()
|
||||
groups, err := aggregateCustomerMileage([]CustomerVehicleAssignment{{VIN: "VIN", Customer: "甲"}}, []CustomerMileageObservation{{VIN: "VIN", Date: "2026-09-01", Restricted: true}}, []string{"2026-09-01"}, "2026-09-30", rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if groups[0].Days[0].DataIssueCount != 0 || groups[0].Days[0].VehicleCount != 0 || groups[0].Vehicles[0].Alert != "restricted" {
|
||||
t.Fatalf("authorization absence misclassified: %+v", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageFuzzyAssignmentFiltersKeepVINScope(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{
|
||||
{VIN: vin, Customer: "上海客户甲", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"},
|
||||
{VIN: "LNX00000000000002", Customer: "上海客户乙", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"},
|
||||
}}
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
q := url.Values{"dateFrom": {"2026-01-10"}, "dateTo": {"2026-01-10"}, "customer": {"上海"}, "department": {" 一部 "}, "responsiblePerson": {"alice"}}
|
||||
result, err := NewService(store).CustomerMileage(ctx, q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Groups) != 1 || result.Groups[0].Customer != "上海客户甲" || store.query.Get("vins") != vin {
|
||||
t.Fatalf("partial filters lost scope: %+v %v", result.Groups, store.query)
|
||||
}
|
||||
q.Set("responsiblePerson", "不存在")
|
||||
result, err = NewService(store).CustomerMileage(ctx, q)
|
||||
if err != nil || len(result.Groups) != 0 {
|
||||
t.Fatalf("non-matching keyword returned data: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomerMileageStore interface {
|
||||
CustomerMileageRules(context.Context, string) (CustomerMileageRules, error)
|
||||
SaveCustomerMileageRules(context.Context, string, CustomerMileageRules, string) (CustomerMileageRules, error)
|
||||
CustomerMileageObservations(context.Context, url.Values) ([]CustomerMileageObservation, error)
|
||||
SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error
|
||||
CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerMileageRules(ctx context.Context, scope string) (CustomerMileageRules, error) {
|
||||
var raw []byte
|
||||
var version int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version,rules_json FROM platform_customer_mileage_rules WHERE scope_key=?`, scope).Scan(&version, &raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
r := DefaultCustomerMileageRules()
|
||||
r.Version = 0
|
||||
return r, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
var r CustomerMileageRules
|
||||
if err = json.Unmarshal(raw, &r); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Version = version
|
||||
return r, r.Validate()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SaveCustomerMileageRules(ctx context.Context, scope string, r CustomerMileageRules, actor string) (CustomerMileageRules, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()}
|
||||
}
|
||||
previous := r.Version
|
||||
r.Version++
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
var result sql.Result
|
||||
if previous == 0 {
|
||||
result, err = s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_mileage_rules(scope_key,version,rules_json,updated_by) VALUES(?,?,?,?)`, scope, r.Version, string(raw), actor)
|
||||
} else {
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE platform_customer_mileage_rules SET version=?,rules_json=?,updated_by=? WHERE scope_key=? AND version=?`, r.Version, string(raw), actor, scope, previous)
|
||||
}
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
if n != 1 {
|
||||
return CustomerMileageRules{}, clientError{Code: "CUSTOMER_MILEAGE_RULE_CONFLICT", Message: "规则已被其他用户更新,请重新加载后保存"}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SaveCustomerAssignments(ctx context.Context, tenant string, rows []CustomerVehicleAssignment, observed time.Time) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// A caller-scoped snapshot never deletes other users' metadata. Blank customer
|
||||
// values deliberately replace old assignments when a vehicle is unassigned.
|
||||
for start := 0; start < len(rows); start += 200 {
|
||||
end := start + 200
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
placeholders := make([]string, 0, end-start)
|
||||
args := make([]any, 0, (end-start)*7)
|
||||
for _, r := range rows[start:end] {
|
||||
placeholders = append(placeholders, "(?,?,?,?,?,?,?)")
|
||||
args = append(args, tenant, r.VIN, r.Plate, r.Customer, r.Department, r.ResponsiblePerson, observed)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_vehicle_assignment(tenant_ref,vin,plate,customer_name,department_name,responsible_person,observed_at) VALUES `+strings.Join(placeholders, ",")+` ON DUPLICATE KEY UPDATE plate=IF(VALUES(observed_at)>=observed_at,VALUES(plate),plate), customer_name=IF(VALUES(observed_at)>=observed_at,VALUES(customer_name),customer_name), department_name=IF(VALUES(observed_at)>=observed_at,VALUES(department_name),department_name), responsible_person=IF(VALUES(observed_at)>=observed_at,VALUES(responsible_person),responsible_person), observed_at=GREATEST(observed_at,VALUES(observed_at))`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerAssignments(ctx context.Context, tenant string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT vin,plate,customer_name,department_name,responsible_person,observed_at FROM platform_customer_vehicle_assignment WHERE tenant_ref=? ORDER BY vin`, tenant)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerVehicleAssignment{}
|
||||
var oldest time.Time
|
||||
for rows.Next() {
|
||||
var a CustomerVehicleAssignment
|
||||
var observed time.Time
|
||||
if err = rows.Scan(&a.VIN, &a.Plate, &a.Customer, &a.Department, &a.ResponsiblePerson, &observed); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
result = append(result, a)
|
||||
if oldest.IsZero() || observed.Before(oldest) {
|
||||
oldest = observed
|
||||
}
|
||||
}
|
||||
return result, oldest, rows.Err()
|
||||
}
|
||||
|
||||
func buildCustomerMileageSQL(q url.Values) SQLQuery {
|
||||
where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.protocol = 'JT808'"}
|
||||
args := []any{q.Get("dateFrom"), q.Get("dateTo")}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom"))
|
||||
return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.daily_mileage_km FROM vehicle_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args}
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerMileageObservations(ctx context.Context, q url.Values) ([]CustomerMileageObservation, error) {
|
||||
built := buildCustomerMileageSQL(q)
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerMileageObservation{}
|
||||
indexes := map[string]int{}
|
||||
for rows.Next() {
|
||||
var vin, date string
|
||||
var daily float64
|
||||
if err = rows.Scan(&vin, &date, &daily); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := vin + "/" + date
|
||||
index, ok := indexes[key]
|
||||
if !ok {
|
||||
index = len(result)
|
||||
indexes[key] = index
|
||||
result = append(result, CustomerMileageObservation{VIN: vin, Date: date})
|
||||
}
|
||||
o := &result[index]
|
||||
if daily < 0 || daily > 3000 {
|
||||
o.QualityReason = "GPS 日里程超出有效范围,需核查累计里程跳变"
|
||||
continue
|
||||
}
|
||||
o.GPSKm = &daily
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
return s.enrichCustomerHydrogen(ctx, q, result)
|
||||
}
|
||||
|
||||
func buildCustomerHydrogenProjectionSQL(q url.Values) SQLQuery {
|
||||
where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.account = 'guangdonglingniu'"}
|
||||
args := []any{q.Get("dateFrom"), q.Get("dateTo")}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom"))
|
||||
return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.hydrogen_km FROM gzev_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args}
|
||||
}
|
||||
|
||||
func (s *ProductionStore) enrichCustomerHydrogen(ctx context.Context, q url.Values, result []CustomerMileageObservation) ([]CustomerMileageObservation, error) {
|
||||
built := buildCustomerHydrogenProjectionSQL(q)
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
indexes := map[string]int{}
|
||||
for i, r := range result {
|
||||
indexes[r.VIN+"/"+r.Date] = i
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var vin, date string
|
||||
var hydrogen sql.NullFloat64
|
||||
if err = rows.Scan(&vin, &date, &hydrogen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := vin + "/" + date
|
||||
if selected[key] {
|
||||
continue
|
||||
}
|
||||
selected[key] = true
|
||||
index, ok := indexes[key]
|
||||
if !ok {
|
||||
index = len(result)
|
||||
indexes[key] = index
|
||||
result = append(result, CustomerMileageObservation{VIN: vin, Date: date})
|
||||
}
|
||||
o := &result[index]
|
||||
o.HydrogenSource = "GZEV / hyRunMileage"
|
||||
if hydrogen.Valid && hydrogen.Float64 >= 0 && hydrogen.Float64 <= 3000 {
|
||||
value := hydrogen.Float64
|
||||
o.HydrogenKm = &value
|
||||
} else if hydrogen.Valid {
|
||||
o.QualityReason = "GZEV 氢能源日里程超出有效范围,需核查源数据"
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomerMileageStoreUsesGZEVAndPreservesMissing(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT m.vin.*vehicle_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "gps"}).AddRow("A", "2026-10-01", 250).AddRow("B", "2026-10-01", 100).AddRow("C", "2026-10-01", 100))
|
||||
mock.ExpectQuery("SELECT m.vin.*gzev_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "hydrogen"}).AddRow("A", "2026-10-01", 180).AddRow("B", "2026-10-01", nil).AddRow("C", "2026-10-01", 0).AddRow("D", "2026-10-01", 80))
|
||||
result, err := (&ProductionStore{db: db}).CustomerMileageObservations(context.Background(), url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-01"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 4 || *result[0].GPSKm != 250 || *result[0].HydrogenKm != 180 || result[0].HydrogenSource != "GZEV / hyRunMileage" {
|
||||
t.Fatalf("wrong source: %+v", result)
|
||||
}
|
||||
if result[1].HydrogenKm != nil || result[2].HydrogenKm == nil || *result[2].HydrogenKm != 0 || result[3].GPSKm != nil {
|
||||
t.Fatal("null, zero, or independent sources lost")
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerGZEVSQLPreservesAuthorization(t *testing.T) {
|
||||
q := url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-07"}, "vins": {"VIN-A"}, "scopeVins": {"VIN-A"}, "scopeVehicleFrom": {`{"VIN-A":"2026-10-03"}`}}
|
||||
built := buildCustomerHydrogenProjectionSQL(q)
|
||||
for _, want := range []string{"gzev_daily_mileage", "m.account = 'guangdonglingniu'", "m.vin IN", "m.stat_date >="} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "VIN-A") {
|
||||
t.Fatal("scope interpolated into SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageRulesConcurrentSaveReturnsConflict(t *testing.T) {
|
||||
for _, version := range []int64{0, 4} {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules := DefaultCustomerMileageRules()
|
||||
rules.Version = version
|
||||
pattern := "UPDATE platform_customer_mileage_rules"
|
||||
if version == 0 {
|
||||
pattern = "INSERT IGNORE INTO platform_customer_mileage_rules"
|
||||
}
|
||||
mock.ExpectExec(pattern).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
_, err = (&ProductionStore{db: db}).SaveCustomerMileageRules(context.Background(), "tenant:000000", rules, "admin")
|
||||
ce, ok := asClientError(err)
|
||||
if !ok || ce.Code != "CUSTOMER_MILEAGE_RULE_CONFLICT" {
|
||||
t.Fatalf("concurrent save lost: %v", err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user