261 lines
12 KiB
Go
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)
|
|
}
|