feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user