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) }