Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/app/auth_demo.go
2026-07-27 16:46:15 +08:00

261 lines
12 KiB
Go

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