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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user