feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -30,6 +30,8 @@ type apiAuthenticator struct {
mode string
tokens []tokenPrincipal
local *authStore
oneOS *oneOSIdentityAdapter
demo *demoAuthDirectory
adapters []IdentityAdapter
}
@@ -60,7 +62,17 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
if err != nil {
return nil, err
}
authenticator := &apiAuthenticator{mode: mode, local: local}
oneOS, err := newOneOSIdentityAdapter(cfg)
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}
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
authenticator.demo = newDemoAuthDirectory()
}
configured := []configuredPrincipal{}
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
@@ -97,6 +109,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/oneos/exchange" {
if r.Method != http.MethodPost {
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r))
return
}
if a.oneOS == nil || a.local == nil {
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_SSO_UNAVAILABLE", "OneOS 单点登录尚未启用", "", requestTraceID(r))
return
}
a.local.exchangeOneOSTicket(w, r, a.oneOS)
return
}
if r.URL.Path == "/api/v2/auth/login" {
if a.local == nil {
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r))
@@ -146,7 +170,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" {
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
a.local.logout(r.Context(), bearerToken(r))
}
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
@@ -163,6 +187,9 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
if a.local != nil && a.local.handleAdmin(w, r, principal) {
return
}
if a.demo != nil && a.demo.handleAdmin(w, r, principal) {
return
}
next.ServeHTTP(w, r)
})
}
@@ -220,9 +247,9 @@ func requiredMenu(r *http.Request) string {
return "shared"
case path == "/api/v2/tracks":
return "tracks"
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary":
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
return "statistics"
case path == "/api/vehicles", path == "/api/vehicles/resolve":
case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
return "shared"
case strings.HasPrefix(path, "/api/v2/vehicles/"):
return "vehicles"
@@ -232,7 +259,14 @@ func requiredMenu(r *http.Request) string {
}
func requiredRole(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
return "admin"
}
if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") {
if r.Method == http.MethodPost && (strings.HasSuffix(r.URL.Path, "/archive") || strings.HasSuffix(r.URL.Path, "/restore") ||
strings.HasSuffix(r.URL.Path, "/batch-archive") || strings.HasSuffix(r.URL.Path, "/batch-restore")) {
return "admin"
}
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost {
return "operator"
}
@@ -255,7 +289,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/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
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":
return "viewer"
case "/api/v2/alerts/notifications/read":
return "operator"
@@ -266,6 +300,9 @@ func requiredRole(r *http.Request) string {
if path == "/api/v2/alerts/rules" {
return "admin"
}
if strings.HasPrefix(path, "/api/v2/alerts/rules/") && strings.HasSuffix(path, "/rollback") {
return "admin"
}
}
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
return "admin"

View File

@@ -0,0 +1,260 @@
package app
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
// demoAuthDirectory keeps account governance operable in DATA_MODE=mock. It is
// intentionally isolated from production authentication and only exists when
// authentication is disabled and no SQL identity store is configured.
type demoAuthDirectory struct {
mu sync.Mutex
users []authUser
nextID uint64
}
func newDemoAuthDirectory() *demoAuthDirectory {
now := time.Now().Truncate(time.Second)
grant := func(vin, plate string, daysAgo int) authVehicleGrant {
return authVehicleGrant{VIN: vin, Plate: plate, ValidFrom: now.AddDate(0, 0, -daysAgo), SourceSystem: "manual", GrantedBy: "demo-admin"}
}
users := []authUser{
{ID: 101, Username: "customer-south", DisplayName: "华南运营中心", UserType: "customer", Status: "enabled", CustomerRef: "CUS-SOUTH", TenantRef: "tenant-south", AuthProvider: "local", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LB9A32A24R0LS1426", "粤AG18312", 120), grant("LB9A32A24P0LS1230", "粤AFF7936", 45)}, LastLoginAt: timePointer(now.Add(-18 * time.Minute)), CreatedAt: now.AddDate(0, -5, 0), UpdatedAt: now.Add(-2 * time.Hour)},
{ID: 102, Username: "oneos-east", DisplayName: "华东数据客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-EAST", TenantRef: "tenant-east", AuthProvider: "OneOS", ExternalSubject: "oneos:tenant-east:customer-002", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LMRKH9AC2R1004087", "豫A88888", 88)}, LastLoginAt: timePointer(now.Add(-3 * time.Hour)), CreatedAt: now.AddDate(0, -4, 0), UpdatedAt: now.Add(-25 * time.Minute)},
{ID: 103, Username: "ruoyi-west", DisplayName: "西区联营客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-WEST", TenantRef: "tenant-west", AuthProvider: "RuoYi", MenuKeys: []string{"monitor", "vehicles"}, Vehicles: []authVehicleGrant{grant("LNXNEGRR7SR318212", "川AHTWO1", 31)}, CreatedAt: now.AddDate(0, -2, 0), UpdatedAt: now.Add(-40 * time.Minute)},
{ID: 104, Username: "customer-archive", DisplayName: "历史合作客户", UserType: "customer", Status: "disabled", CustomerRef: "CUS-ARCHIVE", AuthProvider: "local", MenuKeys: []string{"monitor"}, Vehicles: []authVehicleGrant{grant("LB9A32A24P0LS1230", "粤AFF7936", 200)}, CreatedAt: now.AddDate(-1, 0, 0), UpdatedAt: now.AddDate(0, 0, -12)},
}
for index := range users {
users[index].VehicleVINs = grantVINsFromAuth(users[index].Vehicles)
}
return &demoAuthDirectory{users: users, nextID: 105}
}
var customerMenuKeys = []string{"monitor", "vehicles", "tracks", "statistics"}
func timePointer(value time.Time) *time.Time { return &value }
func grantVINsFromAuth(grants []authVehicleGrant) []string {
vins := make([]string, 0, len(grants))
for _, grant := range grants {
vins = append(vins, grant.VIN)
}
return vins
}
func cloneAuthUsers(users []authUser) []authUser {
result := make([]authUser, len(users))
for index, user := range users {
result[index] = user
result[index].MenuKeys = append([]string(nil), user.MenuKeys...)
result[index].VehicleVINs = append([]string(nil), user.VehicleVINs...)
result[index].Vehicles = append([]authVehicleGrant(nil), user.Vehicles...)
result[index].GrantHistory = append([]authVehicleGrantHistory(nil), user.GrantHistory...)
}
return result
}
func (d *demoAuthDirectory) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
return false
}
if principal.UserType != "admin" && principal.Role != "admin" {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "仅管理员可以管理账号与权限", "", requestTraceID(r))
return true
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
d.mu.Lock()
users := cloneAuthUsers(d.users)
d.mu.Unlock()
httpx.WriteOK(w, requestTraceID(r), users)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
d.batchCustomers(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
d.createCustomer(w, r)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
d.updateCustomer(w, r)
default:
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r))
}
return true
}
func validateDemoCustomer(input userMutation, requirePassword bool) ([]string, []authVehicleGrant, error) {
if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) {
return nil, nil, demoValidationError("用户名需为 3-64 位字母、数字、点、下划线或短横线")
}
if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 {
return nil, nil, demoValidationError("客户名称不能为空且不能超过 48 个字符")
}
if input.Status != "enabled" && input.Status != "disabled" {
return nil, nil, demoValidationError("账号状态无效")
}
if requirePassword || input.Password != "" {
if err := validatePassword(input.Password); err != nil {
return nil, nil, err
}
}
menus := normalizeMenus(input.MenuKeys)
if len(menus) == 0 {
return nil, nil, demoValidationError("至少分配一个客户菜单")
}
mutations, err := normalizeVehicleGrantMutations(input)
if err != nil {
return nil, nil, err
}
if len(mutations) == 0 {
return nil, nil, demoValidationError("至少分配一辆可查看车辆")
}
now := time.Now().Truncate(time.Second)
plates := map[string]string{"LB9A32A24R0LS1426": "粤AG18312", "LNXNEGRR7SR318212": "川AHTWO1", "LMRKH9AC2R1004087": "豫A88888", "LB9A32A24P0LS1230": "粤AFF7936"}
grants := make([]authVehicleGrant, 0, len(mutations))
for _, item := range mutations {
if _, exists := plates[item.VIN]; !exists {
return nil, nil, demoValidationError("车辆不存在或尚未接入:" + item.VIN)
}
validFrom := now
if item.ValidFrom != nil {
validFrom = *item.ValidFrom
}
grants = append(grants, authVehicleGrant{VIN: item.VIN, Plate: plates[item.VIN], ValidFrom: validFrom, ValidTo: item.ValidTo, SourceSystem: "manual", GrantedBy: "local-developer"})
}
return menus, grants, nil
}
type demoValidationError string
func (e demoValidationError) Error() string { return string(e) }
func (d *demoAuthDirectory) createCustomer(w http.ResponseWriter, r *http.Request) {
var input userMutation
if !decodeAuthJSON(w, r, &input) {
return
}
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
menus, grants, err := validateDemoCustomer(input, true)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
return
}
d.mu.Lock()
defer d.mu.Unlock()
for _, user := range d.users {
if strings.EqualFold(user.Username, strings.TrimSpace(input.Username)) {
httpx.WriteError(w, http.StatusConflict, "USERNAME_EXISTS", "登录账号已经存在", input.Username, requestTraceID(r))
return
}
}
now := time.Now().Truncate(time.Second)
id := d.nextID
d.nextID++
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(input.Username), DisplayName: strings.TrimSpace(input.DisplayName), UserType: "customer", Status: input.Status, CustomerRef: strings.TrimSpace(input.CustomerRef), TenantRef: strings.TrimSpace(input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
}
func (d *demoAuthDirectory) updateCustomer(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/"), 10, 64)
if err != nil || id == 0 {
httpx.WriteError(w, http.StatusBadRequest, "USER_ID_INVALID", "账号编号无效", "", requestTraceID(r))
return
}
var input userMutation
if !decodeAuthJSON(w, r, &input) {
return
}
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
menus, grants, err := validateDemoCustomer(input, false)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
return
}
d.mu.Lock()
defer d.mu.Unlock()
for index := range d.users {
user := &d.users[index]
if user.ID != id || user.UserType != "customer" {
continue
}
if !strings.EqualFold(user.AuthProvider, "local") && strings.TrimSpace(user.AuthProvider) != "" && input.Password != "" {
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", user.AuthProvider, requestTraceID(r))
return
}
user.DisplayName = strings.TrimSpace(input.DisplayName)
user.Status = input.Status
user.CustomerRef = strings.TrimSpace(input.CustomerRef)
user.TenantRef = strings.TrimSpace(input.TenantRef)
user.MenuKeys = menus
user.Vehicles = grants
user.VehicleVINs = grantVINsFromAuth(grants)
user.UpdatedAt = time.Now().Truncate(time.Second)
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
return
}
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
}
func (d *demoAuthDirectory) batchCustomers(w http.ResponseWriter, r *http.Request) {
var input userBatchRequest
if !decodeAuthJSON(w, r, &input) {
return
}
if input.Mode != "preview" && input.Mode != "create" {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量处理模式无效", "", requestTraceID(r))
return
}
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", "每批需要包含 1 至 50 个账号", "", requestTraceID(r))
return
}
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
seen := map[string]bool{}
for _, item := range input.Items {
entry := userBatchResultItem{Row: item.Row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
key := strings.ToLower(strings.TrimSpace(item.Input.Username))
if seen[key] {
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内账号重复"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
seen[key] = true
if _, _, err := validateDemoCustomer(item.Input, true); err != nil {
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
d.mu.Lock()
exists := false
for _, user := range d.users {
exists = exists || strings.EqualFold(user.Username, key)
}
d.mu.Unlock()
if exists {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "登录账号已经存在"
result.Summary.Failed++
} else if input.Mode == "preview" {
entry.Status, entry.Message = "ready", "校验通过,可以创建"
result.Summary.Ready++
} else {
menus, grants, _ := validateDemoCustomer(item.Input, true)
d.mu.Lock()
id := d.nextID
d.nextID++
now := time.Now().Truncate(time.Second)
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(item.Input.Username), DisplayName: strings.TrimSpace(item.Input.DisplayName), UserType: "customer", Status: firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled"), CustomerRef: strings.TrimSpace(item.Input.CustomerRef), TenantRef: strings.TrimSpace(item.Input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
d.mu.Unlock()
entry.ID, entry.Status, entry.Message = int64(id), "created", "创建成功"
result.Summary.Created++
}
result.Items = append(result.Items, entry)
}
httpx.WriteOK(w, requestTraceID(r), result)
}

View File

@@ -123,6 +123,41 @@ type userMutation struct {
VehicleGrants []userVehicleGrantInput `json:"vehicleGrants"`
}
const maxUserBatchItems = 50
type userBatchRequest struct {
Mode string `json:"mode"`
Items []userBatchItem `json:"items"`
}
type userBatchItem struct {
Row int `json:"row"`
Input userMutation `json:"input"`
}
type userBatchResultItem struct {
Row int `json:"row"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
ID int64 `json:"id,omitempty"`
}
type userBatchSummary struct {
Received int `json:"received"`
Ready int `json:"ready"`
Created int `json:"created"`
Failed int `json:"failed"`
}
type userBatchResult struct {
Mode string `json:"mode"`
Summary userBatchSummary `json:"summary"`
Items []userBatchResultItem `json:"items"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -139,6 +174,17 @@ type loginResponse struct {
Session platform.Principal `json:"session"`
}
type oneOSTicketExchangeRequest struct {
Ticket string `json:"ticket"`
}
type oneOSTicketExchangeResponse struct {
AccessToken string `json:"accessToken"`
ExpiresAt time.Time `json:"expiresAt"`
Session platform.Principal `json:"session"`
ReturnTo string `json:"returnTo"`
}
type cachedSession struct {
principal platform.Principal
expiresAt time.Time
@@ -146,10 +192,11 @@ type cachedSession struct {
}
type authStore struct {
db *sql.DB
sessionTTL time.Duration
cacheMu sync.RWMutex
cache map[string]cachedSession
db *sql.DB
sessionTTL time.Duration
oneOSSessionTTL time.Duration
cacheMu sync.RWMutex
cache map[string]cachedSession
}
func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
@@ -160,7 +207,11 @@ func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
if ttl <= 0 {
ttl = 12 * time.Hour
}
store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}}
oneOSTTL := cfg.OneOSSessionTTL
if oneOSTTL <= 0 {
oneOSTTL = 30 * time.Minute
}
store := &authStore{db: db, sessionTTL: ttl, oneOSSessionTTL: oneOSTTL, cache: map[string]cachedSession{}}
if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
return nil, err
}
@@ -243,28 +294,168 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
return
}
accessToken, tokenHash, err := randomSessionToken()
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
return
}
sessionID, err := randomHex(16)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
return
}
expiresAt := now.Add(s.sessionTTL)
_, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255))
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
return
}
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
principal.SessionID = sessionID
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r))
w.Header().Set("Cache-Control", "no-store")
httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal})
httpx.WriteOK(w, requestTraceID(r), response)
}
func (s *authStore) exchangeOneOSTicket(w http.ResponseWriter, r *http.Request, adapter *oneOSIdentityAdapter) {
var input oneOSTicketExchangeRequest
if !decodeAuthJSON(w, r, &input) {
return
}
identity, err := adapter.ExchangeTicket(r.Context(), input.Ticket)
if err != nil {
s.audit(r.Context(), "oneos", "sso.exchange", "ticket", "", "denied", map[string]any{"reason": err.Error()}, remoteAddress(r))
httpx.WriteError(w, http.StatusUnauthorized, "ONEOS_TICKET_REJECTED", "OneOS 登录票据无效、已过期或已使用", "", requestTraceID(r))
return
}
user, err := s.syncOneOSIdentity(r.Context(), identity, adapter.defaultMenus)
if err != nil {
s.audit(r.Context(), identity.Username, "sso.exchange", "user", identity.Subject, "failed", map[string]any{"reason": err.Error()}, remoteAddress(r))
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法同步 OneOS 用户权限", "", requestTraceID(r))
return
}
principal, err := s.principalForUser(r.Context(), user)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法加载 OneOS 用户权限", "", requestTraceID(r))
return
}
response, err := s.issueSession(r, user.ID, principal, s.oneOSSessionTTL)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
return
}
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET last_login_at=NOW(3),failed_login_count=0,locked_until=NULL WHERE id=?`, user.ID)
s.audit(r.Context(), principal.Name, "sso.exchange", "user", identity.Subject, "success", map[string]any{
"scopeLevel": identity.ScopeLevel, "departmentIds": identity.DepartmentIDs,
}, remoteAddress(r))
w.Header().Set("Cache-Control", "no-store")
httpx.WriteOK(w, requestTraceID(r), oneOSTicketExchangeResponse{
AccessToken: response.AccessToken,
ExpiresAt: response.ExpiresAt,
Session: response.Session,
ReturnTo: identity.ReturnTo,
})
}
func (s *authStore) syncOneOSIdentity(ctx context.Context, identity oneOSIdentity, menus []string) (authUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return authUser{}, err
}
defer tx.Rollback()
var userID uint64
err = tx.QueryRowContext(ctx, `SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`, identity.Subject).Scan(&userID)
switch {
case errors.Is(err, sql.ErrNoRows):
result, insertErr := tx.ExecContext(ctx, `INSERT INTO platform_user(
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`,
oneOSPlatformUsername(identity.Subject), oneOSDisplayName(identity), "", identity.TenantID, identity.Subject,
)
if insertErr != nil {
return authUser{}, insertErr
}
insertedID, insertErr := result.LastInsertId()
if insertErr != nil {
return authUser{}, insertErr
}
userID = uint64(insertedID)
case err != nil:
return authUser{}, err
default:
if _, err = tx.ExecContext(ctx, `UPDATE platform_user SET display_name=?,tenant_ref=?,status='enabled',updated_by='oneos-sso' WHERE id=?`,
oneOSDisplayName(identity), identity.TenantID, userID); err != nil {
return authUser{}, err
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
return authUser{}, err
}
for _, menu := range menus {
if !customerMenuSet[menu] {
continue
}
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`, userID, menu); err != nil {
return authUser{}, err
}
}
departmentIDs := strings.Join(normalizeStringList(identity.DepartmentIDs, 100), ",")
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_business_scope(
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
) VALUES(?,?,?,?,1,'oneos',?)
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
source_updated_at=VALUES(source_updated_at)`,
userID, identity.ScopeLevel, departmentIDs, strings.TrimSpace(identity.ResponsibleUserID), identity.IssuedAt); err != nil {
return authUser{}, err
}
if err = tx.Commit(); err != nil {
return authUser{}, err
}
s.invalidateUser(userID)
return authUser{
ID: userID, Username: oneOSPlatformUsername(identity.Subject), DisplayName: oneOSDisplayName(identity),
UserType: "customer", Status: "enabled", TenantRef: identity.TenantID,
AuthProvider: "oneos", ExternalSubject: identity.Subject,
}, nil
}
func (s *authStore) issueSession(r *http.Request, userID uint64, principal platform.Principal, ttl time.Duration) (loginResponse, error) {
accessToken, tokenHash, err := randomSessionToken()
if err != nil {
return loginResponse{}, err
}
sessionID, err := randomHex(16)
if err != nil {
return loginResponse{}, err
}
now := time.Now()
if ttl <= 0 {
ttl = 30 * time.Minute
}
expiresAt := now.Add(ttl)
if _, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`,
sessionID, userID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255)); err != nil {
return loginResponse{}, err
}
principal.SessionID = sessionID
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
}
func oneOSPlatformUsername(subject string) string {
normalized := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
return r
}
return '-'
}, strings.TrimSpace(subject))
normalized = strings.Trim(normalized, "-")
if normalized == "" {
sum := sha256.Sum256([]byte(subject))
normalized = hex.EncodeToString(sum[:8])
}
value := "oneos-" + normalized
if len(value) > 64 {
sum := sha256.Sum256([]byte(subject))
value = "oneos-" + hex.EncodeToString(sum[:16])
}
return value
}
func oneOSDisplayName(identity oneOSIdentity) string {
if value := strings.TrimSpace(identity.DisplayName); value != "" {
return truncateUTF8(value, 96)
}
return truncateUTF8(identity.Username, 96)
}
func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) {
@@ -363,6 +554,9 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
menus := append([]string(nil), adminMenus...)
vehicles := []string{}
vehicleGrants := []platform.VehicleGrant{}
businessScopeLevel := ""
departmentIDs := []string{}
responsibleUserID := ""
if user.UserType == "customer" {
menus = []string{}
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
@@ -404,14 +598,96 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
if err := rows.Close(); err != nil {
return platform.Principal{}, err
}
if strings.EqualFold(strings.TrimSpace(user.AuthProvider), "oneos") {
var rawDepartmentIDs string
err := s.db.QueryRowContext(ctx, `SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`, user.ID).Scan(
&businessScopeLevel, &rawDepartmentIDs, &responsibleUserID,
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return platform.Principal{}, err
}
if err == nil {
departmentIDs = normalizeScopeValues(rawDepartmentIDs)
businessVINs, err := s.loadOneOSBusinessVINs(ctx, businessScopeLevel, departmentIDs, responsibleUserID)
if err != nil {
return platform.Principal{}, err
}
vehicles = businessVINs
vehicleGrants = make([]platform.VehicleGrant, 0, len(businessVINs))
for _, vin := range businessVINs {
vehicleGrants = append(vehicleGrants, platform.VehicleGrant{VIN: vin})
}
}
}
}
return platform.Principal{
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, VehicleGrants: vehicleGrants,
BusinessScopeLevel: businessScopeLevel, DepartmentIDs: departmentIDs, ResponsibleUserID: strings.TrimSpace(responsibleUserID),
}, nil
}
func normalizeScopeValues(raw string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range strings.Split(raw, ",") {
value = strings.TrimSpace(value)
if value == "" || seen[value] || len(result) >= 100 {
continue
}
seen[value] = true
result = append(result, value)
}
sort.Strings(result)
return result
}
func (s *authStore) loadOneOSBusinessVINs(ctx context.Context, level string, departmentIDs []string, responsibleUserID string) ([]string, error) {
where := []string{"st.id=1"}
args := []any{}
switch strings.ToLower(strings.TrimSpace(level)) {
case "department":
if len(departmentIDs) == 0 {
return []string{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(departmentIDs)), ",")
where = append(where, "scope.department_id IN ("+placeholders+")")
for _, id := range departmentIDs {
args = append(args, id)
}
case "responsible":
responsibleUserID = strings.TrimSpace(responsibleUserID)
if responsibleUserID == "" {
return []string{}, nil
}
where = append(where, "scope.responsible_user_id=?")
args = append(args, responsibleUserID)
default:
return []string{}, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT UPPER(TRIM(scope.vin))
FROM business_scope_state st
JOIN business_customer_vehicle_scope scope ON BINARY scope.source_version=BINARY st.active_version
WHERE `+strings.Join(where, " AND ")+` AND scope.vin<>'' ORDER BY UPPER(TRIM(scope.vin))`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []string{}
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return nil, err
}
if vin = strings.TrimSpace(vin); vin != "" {
result = append(result, vin)
}
}
return result, rows.Err()
}
func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
return false
@@ -423,6 +699,8 @@ func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principa
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
s.listUsers(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
s.batchCustomers(w, r, principal)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
s.createCustomer(w, r, principal)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
@@ -598,32 +876,127 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
tx, err := s.db.BeginTx(r.Context(), nil)
id, grantChanges, err := s.createCustomerRecord(r.Context(), input, menus, grants, actor.Name)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
return
}
defer tx.Rollback()
result, err := tx.ExecContext(r.Context(), `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name, actor.Name)
if err != nil {
writeUserMutationError(w, r, err, "创建")
return
}
id, _ := result.LastInsertId()
grantChanges := []vehicleGrantAuditChange{}
if err := replaceGrants(r.Context(), tx, uint64(id), menus, grants, actor.Name, &grantChanges); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
return
}
if err := tx.Commit(); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
writeUserMutationError(w, r, err, "创建")
} else {
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
}
return
}
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
}
func (s *authStore) batchCustomers(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
var input userBatchRequest
if !decodeAuthJSON(w, r, &input) {
return
}
input.Mode = strings.ToLower(strings.TrimSpace(input.Mode))
if input.Mode != "preview" && input.Mode != "create" {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量操作模式必须为 preview 或 create", "", requestTraceID(r))
return
}
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", fmt.Sprintf("每次需导入 1-%d 个客户账号", maxUserBatchItems), "", requestTraceID(r))
return
}
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
seen := map[string]bool{}
for index, item := range input.Items {
row := item.Row
if row <= 1 {
row = index + 2
}
item.Input.Username = strings.TrimSpace(item.Input.Username)
item.Input.DisplayName = strings.TrimSpace(item.Input.DisplayName)
item.Input.Status = firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled")
entry := userBatchResultItem{Row: row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
usernameKey := strings.ToLower(item.Input.Username)
if seen[usernameKey] {
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内用户名重复"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
seen[usernameKey] = true
menus, grants, err := s.validateMutation(r.Context(), item.Input, true)
if err != nil {
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
var existingID uint64
err = s.db.QueryRowContext(r.Context(), `SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`, item.Input.Username).Scan(&existingID)
if err == nil {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
if !errors.Is(err, sql.ErrNoRows) {
entry.Status, entry.Code, entry.Message = "failed", "USER_CHECK_FAILED", "暂时无法校验用户名"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
if input.Mode == "preview" {
entry.Status, entry.Message = "ready", "校验通过,可以创建"
result.Summary.Ready++
result.Items = append(result.Items, entry)
continue
}
id, grantChanges, err := s.createCustomerRecord(r.Context(), item.Input, menus, grants, actor.Name)
if err != nil {
entry.Status, entry.Code, entry.Message = "failed", "USER_CREATE_FAILED", "创建失败,请重试"
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
}
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
entry.Status, entry.Message, entry.ID = "created", "账号与权限已创建", id
result.Summary.Created++
result.Items = append(result.Items, entry)
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"batch": true, "row": row, "menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
}
s.audit(r.Context(), actor.Name, "user.batch."+input.Mode, "user_batch", strconv.Itoa(len(input.Items)), "success", map[string]any{"received": result.Summary.Received, "ready": result.Summary.Ready, "created": result.Summary.Created, "failed": result.Summary.Failed}, remoteAddress(r))
httpx.WriteOK(w, requestTraceID(r), result)
}
func (s *authStore) createCustomerRecord(ctx context.Context, input userMutation, menus []string, grants []vehicleGrantMutation, actor string) (int64, []vehicleGrantAuditChange, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return 0, nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, nil, err
}
defer tx.Rollback()
created, err := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor, actor)
if err != nil {
return 0, nil, err
}
id, err := created.LastInsertId()
if err != nil {
return 0, nil, err
}
grantChanges := []vehicleGrantAuditChange{}
if err := replaceGrants(ctx, tx, uint64(id), menus, grants, actor, &grantChanges); err != nil {
return 0, nil, err
}
if err := tx.Commit(); err != nil {
return 0, nil, err
}
return id, grantChanges, nil
}
func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
idText := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/")
id, err := strconv.ParseUint(idText, 10, 64)
@@ -647,11 +1020,15 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
return
}
defer tx.Rollback()
var userType string
if err := tx.QueryRowContext(r.Context(), `SELECT user_type FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType); err != nil || userType != "customer" {
var userType, authProvider string
if err := tx.QueryRowContext(r.Context(), `SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType, &authProvider); err != nil || userType != "customer" {
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
return
}
if authProvider = strings.TrimSpace(authProvider); authProvider != "" && !strings.EqualFold(authProvider, "local") && input.Password != "" {
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", authProvider, requestTraceID(r))
return
}
args := []any{strings.TrimSpace(input.DisplayName), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name}
query := `UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=?`
if input.Password != "" {

View File

@@ -1,14 +1,177 @@
package app
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func TestBatchCustomersPreviewsReadyRowsAndFileDuplicatesIndependently(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
input := userBatchRequest{Mode: "preview", Items: []userBatchItem{
{Row: 2, Input: userMutation{Username: "customer-east", DisplayName: "华东客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
{Row: 3, Input: userMutation{Username: "CUSTOMER-EAST", DisplayName: "重复客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
}}
body, _ := json.Marshal(input)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`)).
WithArgs("customer-east").WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`)).
WillReturnResult(sqlmock.NewResult(1, 1))
request := httptest.NewRequest(http.MethodPost, "/api/v2/admin/users/batch", bytes.NewReader(body))
response := httptest.NewRecorder()
store.batchCustomers(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
if response.Code != http.StatusOK {
t.Fatalf("batch preview failed: status=%d body=%s", response.Code, response.Body.String())
}
var envelope struct {
Data userBatchResult `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.Summary.Ready != 1 || envelope.Data.Summary.Failed != 1 || len(envelope.Data.Items) != 2 {
t.Fatalf("unexpected batch summary: %+v", envelope.Data)
}
if envelope.Data.Items[0].Status != "ready" || envelope.Data.Items[1].Code != "DUPLICATE_IN_FILE" {
t.Fatalf("unexpected batch items: %+v", envelope.Data.Items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSDepartmentRoleUsesDepartmentVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("department", "40002,40001", "50001"))
mock.ExpectQuery("SELECT DISTINCT UPPER").
WithArgs("40001", "40002").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN-A").AddRow("VIN-B"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 7, DisplayName: "部门负责人", Username: "leader", UserType: "customer", AuthProvider: "OneOS",
})
if err != nil {
t.Fatal(err)
}
if principal.BusinessScopeLevel != "department" || strings.Join(principal.DepartmentIDs, ",") != "40001,40002" || strings.Join(principal.VehicleVINs, ",") != "VIN-A,VIN-B" {
t.Fatalf("unexpected OneOS department principal: %+v", principal)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}).AddRow("MANUAL-VIN", time.Now(), nil))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("responsible", "", "50008"))
mock.ExpectQuery("SELECT DISTINCT UPPER").
WithArgs("50008").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("OWN-VIN"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 8, DisplayName: "普通业务", Username: "seller", UserType: "customer", AuthProvider: "oneos",
})
if err != nil {
t.Fatal(err)
}
if principal.BusinessScopeLevel != "responsible" || principal.ResponsibleUserID != "50008" || strings.Join(principal.VehicleVINs, ",") != "OWN-VIN" {
t.Fatalf("ordinary OneOS user must only see responsible vehicles: %+v", principal)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSyncOneOSIdentityCreatesCustomerMenusAndResponsibleScopeAtomically(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
issuedAt := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`)).
WithArgs("50008").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user(
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`)).
WithArgs("oneos-50008", "业务人员", "", "000000", "50008").
WillReturnResult(sqlmock.NewResult(18, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).
WithArgs(uint64(18)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`)).
WithArgs(uint64(18), "vehicles").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_business_scope(
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
) VALUES(?,?,?,?,1,'oneos',?)
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
source_updated_at=VALUES(source_updated_at)`)).
WithArgs(uint64(18), "responsible", "40002,40001", "50008", issuedAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
user, err := store.syncOneOSIdentity(context.Background(), oneOSIdentity{
Subject: "50008", Username: "seller", DisplayName: "业务人员", TenantID: "000000",
DepartmentIDs: []string{"40002", "40001"}, ScopeLevel: "responsible",
ResponsibleUserID: "50008", IssuedAt: issuedAt,
}, []string{"vehicles"})
if err != nil {
t.Fatal(err)
}
if user.ID != 18 || user.Username != "oneos-50008" || user.AuthProvider != "oneos" || user.UserType != "customer" {
t.Fatalf("unexpected synced user: %+v", user)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -210,3 +373,36 @@ func TestNormalizeVehicleGrantMutationsRequiresOrderedInterval(t *testing.T) {
t.Fatalf("valid authorization interval was not normalized: grants=%+v err=%v", grants, err)
}
}
func TestUpdateCustomerRejectsPasswordResetForExternalIdentity(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
input := userMutation{
DisplayName: "华东外部客户",
Password: "ChangeMe2026!",
Status: "enabled",
MenuKeys: []string{"monitor"},
VehicleVINs: []string{"VIN001"},
}
body, _ := json.Marshal(input)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"user_type", "auth_provider"}).AddRow("customer", "OneOS"))
mock.ExpectRollback()
request := httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/7", bytes.NewReader(body))
response := httptest.NewRecorder()
store.updateCustomer(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
t.Fatalf("external password reset should be rejected: status=%d body=%s", response.Code, response.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,6 +1,7 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -86,6 +87,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorRule.Code != http.StatusForbidden {
t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code)
}
operatorRollback := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules/rule-1/rollback", operatorToken)
if operatorRollback.Code != http.StatusForbidden {
t.Fatalf("operator rule rollback should be forbidden, status=%d", operatorRollback.Code)
}
operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken)
if operatorProfile.Code != http.StatusForbidden {
t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code)
@@ -94,6 +99,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorProfileSync.Code != http.StatusForbidden {
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
}
operatorIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", operatorToken)
if operatorIdentityClaim.Code != http.StatusForbidden {
t.Fatalf("operator identity claim should be forbidden, status=%d", operatorIdentityClaim.Code)
}
viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken)
if viewerSourceDiagnostic.Code != http.StatusForbidden {
t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code)
@@ -114,6 +123,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorReconciliation.Code != http.StatusNoContent {
t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String())
}
operatorReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", operatorToken)
if operatorReconciliationArchive.Code != http.StatusForbidden {
t.Fatalf("operator reconciliation archive should be forbidden, status=%d", operatorReconciliationArchive.Code)
}
adminReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", adminToken)
if adminReconciliationArchive.Code != http.StatusNoContent {
t.Fatalf("admin reconciliation archive status=%d body=%s", adminReconciliationArchive.Code, adminReconciliationArchive.Body.String())
}
operatorOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", operatorToken)
if operatorOpenPlatform.Code != http.StatusForbidden {
t.Fatalf("operator open-platform management should be forbidden, status=%d", operatorOpenPlatform.Code)
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))
@@ -126,10 +147,45 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if adminThreshold.Code != http.StatusNoContent {
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
}
adminIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", adminToken)
if adminIdentityClaim.Code != http.StatusNoContent {
t.Fatalf("admin identity claim status=%d body=%s", adminIdentityClaim.Code, adminIdentityClaim.Body.String())
}
adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken)
if adminSourcePolicy.Code != http.StatusNoContent {
t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String())
}
adminOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", adminToken)
if adminOpenPlatform.Code != http.StatusNoContent {
t.Fatalf("admin open-platform management status=%d body=%s", adminOpenPlatform.Code, adminOpenPlatform.Body.String())
}
}
func TestDisabledMockModeProvidesOperableIdentityDirectory(t *testing.T) {
cfg := config.Config{AuthMode: "disabled", DataMode: "mock"}
handler := withAPIAuth(http.NotFoundHandler(), cfg)
listResponse := httptest.NewRecorder()
handler.ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil))
if listResponse.Code != http.StatusOK {
t.Fatalf("mock directory status=%d body=%s", listResponse.Code, listResponse.Body.String())
}
var listEnvelope struct {
Data []authUser `json:"data"`
}
if err := json.Unmarshal(listResponse.Body.Bytes(), &listEnvelope); err != nil {
t.Fatal(err)
}
if len(listEnvelope.Data) < 4 || listEnvelope.Data[1].AuthProvider != "OneOS" || listEnvelope.Data[1].ExternalSubject == "" || listEnvelope.Data[2].ExternalSubject != "" {
t.Fatalf("mock identity examples are incomplete: %+v", listEnvelope.Data)
}
updateBody := `{"displayName":"华东数据客户","password":"ChangeMe2026!","status":"enabled","customerRef":"CUS-EAST","tenantRef":"tenant-east","menuKeys":["monitor"],"vehicleVins":["LMRKH9AC2R1004087"]}`
updateResponse := httptest.NewRecorder()
handler.ServeHTTP(updateResponse, httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/102", strings.NewReader(updateBody)))
if updateResponse.Code != http.StatusBadRequest || !strings.Contains(updateResponse.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
t.Fatalf("mock external credential ownership was not enforced: status=%d body=%s", updateResponse.Code, updateResponse.Body.String())
}
}
func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
@@ -156,6 +212,18 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
}
}
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
if role := requiredRole(req); role != "viewer" {
t.Fatalf("%s should allow authenticated customers, required role=%s", path, role)
}
if menu := requiredMenu(req); menu != "statistics" {
t.Fatalf("%s should use statistics menu scope, required menu=%s", path, menu)
}
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{
{AuthMode: "enforce"},

View File

@@ -0,0 +1,215 @@
package app
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
type oneOSIdentity struct {
Subject string `json:"subject"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
TenantID string `json:"tenantId"`
DepartmentIDs []string `json:"departmentIds"`
DepartmentNames string `json:"departmentNames"`
Permissions []string `json:"permissions"`
ScopeLevel string `json:"scopeLevel"`
ResponsibleUserID string `json:"responsibleUserId"`
Audience string `json:"audience"`
ReturnTo string `json:"returnTo"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type oneOSIntrospectionEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data oneOSIdentity `json:"data"`
}
type oneOSIdentityAdapter struct {
endpoint *url.URL
serviceToken string
signingKey []byte
audience string
defaultMenus []string
client *http.Client
}
const oneOSIntrospectionCanonicalPath = "/inner/v1/sso/vehicle-platform/introspect"
func newOneOSIdentityAdapter(cfg config.Config) (*oneOSIdentityAdapter, error) {
if !cfg.OneOSSSOEnabled {
return nil, nil
}
rawEndpoint := strings.TrimSpace(cfg.OneOSIntrospectionURL)
endpoint, err := url.Parse(rawEndpoint)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" {
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must be an absolute HTTP(S) URL")
}
if endpoint.Scheme != "https" && !isLoopbackHost(endpoint.Hostname()) {
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must use HTTPS outside localhost")
}
serviceToken := strings.TrimSpace(cfg.OneOSServiceToken)
signingSecret := strings.TrimSpace(cfg.OneOSSigningSecret)
if len(serviceToken) < 24 {
return nil, fmt.Errorf("ONEOS_SSO_SERVICE_TOKEN must contain at least 24 characters")
}
if len(signingSecret) < 32 {
return nil, fmt.Errorf("ONEOS_SSO_SIGNING_SECRET must contain at least 32 characters")
}
audience := strings.TrimSpace(cfg.OneOSAudience)
if audience == "" {
audience = "vehicle-platform"
}
timeout := cfg.OneOSRequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
defaultMenus := make([]string, 0, len(cfg.OneOSDefaultMenus))
for _, menu := range normalizeStringList(cfg.OneOSDefaultMenus, 4) {
if customerMenuSet[menu] {
defaultMenus = append(defaultMenus, menu)
}
}
if len(defaultMenus) == 0 {
defaultMenus = []string{"vehicles"}
}
return &oneOSIdentityAdapter{
endpoint: endpoint, serviceToken: serviceToken, signingKey: []byte(signingSecret),
audience: audience, defaultMenus: defaultMenus, client: &http.Client{Timeout: timeout},
}, nil
}
func (a *oneOSIdentityAdapter) ExchangeTicket(ctx context.Context, ticket string) (oneOSIdentity, error) {
ticket = strings.TrimSpace(ticket)
if len(ticket) < 32 || len(ticket) > 256 {
return oneOSIdentity{}, fmt.Errorf("invalid ticket")
}
body, err := json.Marshal(struct {
Ticket string `json:"ticket"`
Audience string `json:"audience"`
}{Ticket: ticket, Audience: a.audience})
if err != nil {
return oneOSIdentity{}, err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
requestID, err := secureRequestID()
if err != nil {
return oneOSIdentity{}, err
}
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
mac := hmac.New(sha256.New, a.signingKey)
_, _ = mac.Write([]byte(canonical))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint.String(), bytes.NewReader(body))
if err != nil {
return oneOSIdentity{}, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Service "+a.serviceToken)
request.Header.Set("X-OneOS-Timestamp", timestamp)
request.Header.Set("X-OneOS-Request-Id", requestID)
request.Header.Set("X-OneOS-Signature", hex.EncodeToString(mac.Sum(nil)))
response, err := a.client.Do(request)
if err != nil {
return oneOSIdentity{}, fmt.Errorf("call OneOS introspection: %w", err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return oneOSIdentity{}, fmt.Errorf("read OneOS introspection: %w", err)
}
var envelope oneOSIntrospectionEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return oneOSIdentity{}, fmt.Errorf("decode OneOS introspection response: %w", err)
}
if response.StatusCode != http.StatusOK || envelope.Code != 200 {
return oneOSIdentity{}, fmt.Errorf("OneOS rejected ticket: status=%d code=%d message=%s", response.StatusCode, envelope.Code, envelope.Msg)
}
identity := envelope.Data
if strings.TrimSpace(identity.Subject) == "" || strings.TrimSpace(identity.Username) == "" {
return oneOSIdentity{}, fmt.Errorf("OneOS identity is incomplete")
}
if !hmac.Equal([]byte(identity.Audience), []byte(a.audience)) {
return oneOSIdentity{}, fmt.Errorf("OneOS identity audience mismatch")
}
now := time.Now()
if identity.ExpiresAt.IsZero() || !identity.ExpiresAt.After(now) || identity.IssuedAt.After(now.Add(time.Minute)) {
return oneOSIdentity{}, fmt.Errorf("OneOS ticket has expired or has an invalid issue time")
}
switch identity.ScopeLevel {
case "department":
if len(normalizeStringList(identity.DepartmentIDs, 100)) == 0 {
return oneOSIdentity{}, fmt.Errorf("OneOS department scope has no department")
}
case "responsible":
if strings.TrimSpace(identity.ResponsibleUserID) == "" {
return oneOSIdentity{}, fmt.Errorf("OneOS responsible scope has no responsible user")
}
default:
return oneOSIdentity{}, fmt.Errorf("OneOS identity has unsupported scope")
}
identity.DepartmentIDs = normalizeStringList(identity.DepartmentIDs, 100)
identity.ReturnTo = safePlatformReturnTo(identity.ReturnTo)
return identity, nil
}
func secureRequestID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}
func normalizeStringList(values []string, limit int) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] || len(result) >= limit {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func safePlatformReturnTo(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") {
return "/vehicles"
}
for _, prefix := range []string{"/vehicles", "/monitor", "/tracks", "/statistics"} {
if value == prefix || strings.HasPrefix(value, prefix+"/") || strings.HasPrefix(value, prefix+"?") {
return value
}
}
return "/vehicles"
}
func isLoopbackHost(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}

View File

@@ -0,0 +1,96 @@
package app
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestOneOSIdentityAdapterSignsAndValidatesIntrospection(t *testing.T) {
const serviceToken = "service-token-with-more-than-24-characters"
const signingSecret = "signing-secret-with-at-least-32-characters"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
timestamp := r.Header.Get("X-OneOS-Timestamp")
requestID := r.Header.Get("X-OneOS-Request-Id")
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
mac := hmac.New(sha256.New, []byte(signingSecret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("Authorization") != "Service "+serviceToken {
t.Errorf("unexpected service authorization")
}
if !hmac.Equal([]byte(r.Header.Get("X-OneOS-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
t.Errorf("unexpected HMAC signature")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"msg": "操作成功",
"data": map[string]any{
"subject": "50008", "username": "seller", "displayName": "业务人员",
"tenantId": "000000", "departmentIds": []string{"40002", "40001", "40001"},
"scopeLevel": "responsible", "responsibleUserId": "50008",
"audience": "vehicle-platform", "returnTo": "https://evil.example/",
"issuedAt": time.Now().Add(-time.Second), "expiresAt": time.Now().Add(time.Minute),
},
})
}))
defer server.Close()
adapter, err := newOneOSIdentityAdapter(config.Config{
OneOSSSOEnabled: true,
OneOSIntrospectionURL: server.URL + "/auth" + oneOSIntrospectionCanonicalPath,
OneOSServiceToken: serviceToken,
OneOSSigningSecret: signingSecret,
OneOSAudience: "vehicle-platform",
OneOSDefaultMenus: []string{"vehicles", "users", "vehicles"},
})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ExchangeTicket(context.Background(), strings.Repeat("a", 64))
if err != nil {
t.Fatal(err)
}
if identity.Subject != "50008" || identity.ReturnTo != "/vehicles" || strings.Join(identity.DepartmentIDs, ",") != "40002,40001" {
t.Fatalf("unexpected identity: %+v", identity)
}
if strings.Join(adapter.defaultMenus, ",") != "vehicles" {
t.Fatalf("unexpected menus: %v", adapter.defaultMenus)
}
}
func TestOneOSIdentityAdapterRequiresHTTPSOutsideLoopback(t *testing.T) {
_, err := newOneOSIdentityAdapter(config.Config{
OneOSSSOEnabled: true,
OneOSIntrospectionURL: "http://oneos.example.com/auth" + oneOSIntrospectionCanonicalPath,
OneOSServiceToken: strings.Repeat("t", 24),
OneOSSigningSecret: strings.Repeat("s", 32),
})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("expected HTTPS validation error, got %v", err)
}
}
func TestSafePlatformReturnToOnlyAllowsKnownApplicationRoutes(t *testing.T) {
for input, expected := range map[string]string{
"/tracks?vin=VIN001": "/tracks?vin=VIN001",
"//evil.example": "/vehicles",
"https://evil.test": "/vehicles",
"/users": "/vehicles",
} {
if actual := safePlatformReturnTo(input); actual != expected {
t.Fatalf("safePlatformReturnTo(%q)=%q want %q", input, actual, expected)
}
}
}

View File

@@ -12,6 +12,7 @@ import (
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
@@ -19,6 +20,7 @@ import (
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
"lingniu/vehicle-data-platform/apps/api/internal/static"
)
@@ -72,7 +74,12 @@ func NewServer(cfg config.Config) http.Handler {
log.Printf("production mysql store enabled")
}
}
var api http.Handler = platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
host, _ := os.Hostname()
workerHost := strings.TrimSpace(host)
if workerHost == "" {
workerHost = "api"
}
platformService := platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
DataMode: dataMode,
ExportDir: strings.TrimSpace(cfg.ExportDir),
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
@@ -84,13 +91,30 @@ func NewServer(cfg config.Config) http.Handler {
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode),
AlertStreamConsumerGroup: strings.TrimSpace(cfg.AlertStreamKafkaGroup),
}))
AlertNotificationConfig: alertNotificationConfig(cfg, dataMode),
HistoryCleanupAutomation: cfg.HistoryCleanupAutomation,
HistoryCleanupPoll: cfg.HistoryCleanupPollInterval,
HistoryCleanupLease: cfg.HistoryCleanupLease,
HistoryCleanupWorkerID: "cleanup-scheduler-" + workerHost,
})
platformService.StartHistoryExportCleanupAutomation(context.Background())
var api http.Handler = platform.NewHandler(platformService)
if storeErr != nil {
log.Printf("platform data store unavailable: %v", storeErr)
api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r))
})
}
if authDB != nil && storeErr == nil {
openPlatformHandler := openplatform.NewAdminHandler(
openplatform.NewService(openplatform.NewMySQLRepository(authDB)),
openplatform.NewPortalService(authDB, cfg.SessionTTL),
)
internalMux := http.NewServeMux()
internalMux.Handle("/api/v2/open-platform/", openPlatformHandler)
internalMux.Handle("/", api)
api = internalMux
}
// 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)
@@ -100,6 +124,32 @@ func NewServer(cfg config.Config) http.Handler {
return withRequestTimeout(handler, cfg.RequestTimeout)
}
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
channels := []platform.AlertNotificationChannelCapability{
{Channel: "in_app", Label: "站内信", Configured: true},
{Channel: "sms", Label: "短信", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationSMSURL) != "" && strings.TrimSpace(cfg.AlertNotificationSMSSecret) != ""},
{Channel: "email", Label: "邮件", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationEmailURL) != "" && strings.TrimSpace(cfg.AlertNotificationEmailSecret) != ""},
{Channel: "wecom", Label: "企业通讯", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationWeComURL) != "" && strings.TrimSpace(cfg.AlertNotificationWeComSecret) != ""},
}
targets := []platform.AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}
raw := strings.TrimSpace(cfg.AlertNotificationTargetsJSON)
if raw == "" && dataMode == "mock" {
targets = []platform.AlertNotificationTargetOption{
{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app", "email", "wecom"}},
{ID: "night-shift", Label: "夜班负责人", Channels: []string{"in_app", "sms", "wecom"}},
{ID: "data-platform", Label: "数据平台负责人", Channels: []string{"in_app", "email", "wecom"}},
}
} else if raw != "" {
var configured []platform.AlertNotificationTargetOption
if err := json.Unmarshal([]byte(raw), &configured); err != nil {
log.Printf("alert notification target catalog ignored: %v", err)
} else {
targets = configured
}
}
return platform.NormalizeAlertNotificationConfig(platform.AlertNotificationConfig{Targets: targets, Channels: channels})
}
func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
type appConfig struct {
AMapWebJSKey string `json:"amapWebJsKey,omitempty"`

View File

@@ -44,6 +44,40 @@ func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
}
}
func TestAlertNotificationConfigExposesOnlyReadyChannelsAndTargetReferences(t *testing.T) {
cfg := config.Config{
AlertNotificationTargetsJSON: `[{"id":"night-shift","label":"夜班负责人","channels":["sms","wecom"]}]`,
AlertNotificationSMSURL: "https://gateway.example.test/sms",
AlertNotificationSMSSecret: "signing-secret",
AlertNotificationEmailURL: "https://gateway.example.test/email",
}
result := alertNotificationConfig(cfg, "production")
if len(result.Channels) != 4 || !result.Channels[0].Configured || !result.Channels[1].Configured || result.Channels[2].Configured || result.Channels[3].Configured {
t.Fatalf("gateway readiness must require both endpoint and secret: %+v", result.Channels)
}
if len(result.Targets) != 2 || result.Targets[0].ID != "platform-operators" || result.Targets[1].ID != "night-shift" {
t.Fatalf("target catalog was not normalized: %+v", result.Targets)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "signing-secret") || strings.Contains(string(encoded), "gateway.example.test") {
t.Fatalf("public notification config leaked gateway credentials: %s", encoded)
}
}
func TestOpenPlatformDocsAreServedOnlyByStandaloneService(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
for _, path := range []string{"/open-api/docs/", "/open-api/swagger/", "/open-api/openapi.yaml"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("internal service unexpectedly served standalone docs: path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
}
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
@@ -380,3 +414,20 @@ func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) {
t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestServerDoesNotExposeOpenPlatformPublicDataRoutes(t *testing.T) {
handler := NewServer(config.Config{
AuthMode: "enforce",
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
DataMode: "mock",
RequestTimeout: time.Second,
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/vehicles/hydrogen-consumption/query", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("internal server should not expose anonymous open-platform data routes: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}