package server import ( "crypto/hmac" "crypto/sha256" "database/sql" "encoding/csv" "encoding/hex" "encoding/json" "io" "strings" "unicode" "unicode/utf8" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "vdi-cloud/platform/internal/store" ) type importInput struct { Kind string `json:"kind" binding:"required,oneof=users terminals bindings"` CSV string `json:"csv" binding:"required,max=524288"` PreviewToken string `json:"previewToken"` } type importRow struct { Line int `json:"line"` Key string `json:"key"` Values map[string]string `json:"values"` Errors []string `json:"errors"` userID, terminalID string desktops []string state string } type importPreview struct { Kind string `json:"kind"` TotalRows int `json:"totalRows"` ValidRows int `json:"validRows"` ErrorRows int `json:"errorRows"` PreviewToken string `json:"previewToken"` Rows []importRow `json:"rows"` } type importItem struct { Line int `json:"line"` Key string `json:"key"` ID string `json:"id"` } type activationItem struct { SN string `json:"sn"` ActivationSecret string `json:"activationSecret"` } type importResult struct { ID string `json:"id"` Kind string `json:"kind"` Count int `json:"count"` Items []importItem `json:"items"` ActivationSecrets []activationItem `json:"activationSecrets"` Replayed bool `json:"replayed"` } func importText(v string, min, max int) bool { n := utf8.RuneCountInString(v) return n >= min && n <= max && !strings.ContainsFunc(v, func(r rune) bool { return unicode.IsControl(r) }) } func parseImport(in importInput) ([]importRow, string) { if !utf8.ValidString(in.CSV) || len(in.CSV) > 524288 { return nil, "请使用不超过 512 KiB 的 UTF-8 CSV 文件" } r := csv.NewReader(strings.NewReader(strings.TrimPrefix(in.CSV, "\ufeff"))) r.FieldsPerRecord = -1 header, err := r.Read() if err != nil { return nil, "无法读取 CSV 表头" } required := map[string][]string{"users": {"username", "displayName"}, "terminals": {"sn", "name", "group"}, "bindings": {"username", "terminalSn", "desktopIds"}}[in.Kind] if len(header) != len(required) { return nil, "CSV 表头必须为:" + strings.Join(required, ",") } positions := map[string]int{} for i, h := range header { h = strings.TrimSpace(h) if _, ok := positions[h]; ok { return nil, "CSV 表头重复" } positions[h] = i } for _, h := range required { if _, ok := positions[h]; !ok { return nil, "CSV 表头必须为:" + strings.Join(required, ",") } } rows := []importRow{} seen := map[string]bool{} seenSN := map[string]bool{} for { cells, err := r.Read() if err == io.EOF { break } if err != nil { return nil, "CSV 引号或字段格式错误,请检查文件" } if len(rows) >= 200 { return nil, "每次最多导入 200 行,请拆分文件" } line, _ := r.FieldPos(0) row := importRow{Line: line, Values: map[string]string{}, Errors: []string{}} if len(cells) != len(header) { row.Errors = append(row.Errors, "字段数量与表头不一致") rows = append(rows, row) continue } for h, i := range positions { row.Values[h] = strings.TrimSpace(cells[i]) } row.Key = row.Values["username"] if in.Kind == "terminals" { row.Key = row.Values["sn"] } if seen[row.Key] { row.Errors = append(row.Errors, "文件内账号或 SN 重复") } seen[row.Key] = true if in.Kind == "users" { if !importText(row.Key, 2, 80) || !importText(row.Values["displayName"], 1, 80) { row.Errors = append(row.Errors, "账号需 2~80 字符,姓名需 1~80 字符,不能包含控制字符") } } else if in.Kind == "terminals" { if !importText(row.Key, 3, 100) || !importText(row.Values["name"], 1, 80) || !importText(row.Values["group"], 0, 80) { row.Errors = append(row.Errors, "SN 需 3~100 字符,名称需 1~80 字符,分组最多 80 字符,不能包含控制字符") } } else { sn := row.Values["terminalSn"] if !importText(row.Key, 2, 80) || !importText(sn, 0, 100) { row.Errors = append(row.Errors, "用户账号或终端 SN 格式无效") } if sn != "" { if seenSN[sn] { row.Errors = append(row.Errors, "文件内同一终端不能分配给多位用户") } seenSN[sn] = true } if row.Values["desktopIds"] != "" { row.desktops = uniqueIDs(strings.Split(row.Values["desktopIds"], ";")) for _, id := range row.desktops { if !importText(id, 1, 100) { row.Errors = append(row.Errors, "桌面 ID 格式无效,多个 ID 使用英文分号分隔") break } } } if len(row.desktops) > 100 { row.Errors = append(row.Errors, "每位用户最多追加 100 台桌面") } if sn == "" && len(row.desktops) == 0 { row.Errors = append(row.Errors, "终端 SN 和桌面 ID 至少填写一项") } } rows = append(rows, row) } if len(rows) == 0 { return nil, "CSV 没有数据行" } return rows, "" } func (s *Server) validateImport(c *gin.Context, tx *sqlx.Tx, in importInput) (importPreview, bool) { rows, message := parseImport(in) if message != "" { s.fail(c, 400, "invalid_csv", message) return importPreview{}, false } ctx := c.Request.Context() state := []string{} for i := range rows { row := &rows[i] if len(row.Errors) > 0 { continue } if in.Kind == "users" || in.Kind == "terminals" { var exists bool query := "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)" if in.Kind == "terminals" { query = "SELECT EXISTS(SELECT 1 FROM terminals WHERE sn=$1)" } if err := tx.GetContext(ctx, &exists, query, row.Key); err != nil { s.dbError(c, err) return importPreview{}, false } if exists { row.Errors = append(row.Errors, "该账号或 SN 已存在;导入仅新增,不覆盖现有资料") } } else { var u struct { ID string `db:"id"` TerminalID string `db:"terminal_id"` Enabled bool `db:"enabled"` } err := tx.GetContext(ctx, &u, "SELECT id,COALESCE(terminal_id,'') terminal_id,enabled FROM users WHERE username=$1", row.Key) if err == sql.ErrNoRows { row.Errors = append(row.Errors, "用户不存在,请先导入用户") continue } if err != nil { s.dbError(c, err) return importPreview{}, false } row.userID = u.ID oldDesktops := []string{} if err = tx.SelectContext(ctx, &oldDesktops, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", u.ID); err != nil { s.dbError(c, err) return importPreview{}, false } snapshot, _ := json.Marshal([]any{u.ID, u.TerminalID, u.Enabled, oldDesktops}) row.state = string(snapshot) if sn := row.Values["terminalSn"]; sn != "" { var t struct { ID string `db:"id"` Enabled bool `db:"enabled"` Owner string `db:"owner"` } err = tx.GetContext(ctx, &t, "SELECT t.id,t.enabled,COALESCE(u.id,'') owner FROM terminals t LEFT JOIN users u ON u.terminal_id=t.id WHERE t.sn=$1", sn) if err == sql.ErrNoRows { row.Errors = append(row.Errors, "终端 SN 不存在") } else if err != nil { s.dbError(c, err) return importPreview{}, false } else { if !t.Enabled { row.Errors = append(row.Errors, "终端已停用") } if t.Owner != "" && t.Owner != u.ID { row.Errors = append(row.Errors, "该终端已关联其他用户") } if u.TerminalID != "" && u.TerminalID != t.ID { row.Errors = append(row.Errors, "该用户已关联其他终端,请在用户页面明确换绑") } row.terminalID = t.ID row.state += "|" + t.ID } } if len(row.desktops) > 0 { var count int if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", row.desktops); err != nil { s.dbError(c, err) return importPreview{}, false } if count != len(row.desktops) { row.Errors = append(row.Errors, "存在未纳管的桌面 ID") } } } state = append(state, row.state) } out := importPreview{Kind: in.Kind, TotalRows: len(rows), Rows: rows} for _, row := range rows { if len(row.Errors) == 0 { out.ValidRows++ } else { out.ErrorRows++ } } if out.ErrorRows == 0 { raw, _ := json.Marshal([]any{in.Kind, in.CSV, state}) mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret)) mac.Write(raw) out.PreviewToken = hex.EncodeToString(mac.Sum(nil)) } return out, true } func (s *Server) previewImport(c *gin.Context) { var in importInput if !s.decode(c, &in) { return } tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if out, ok := s.validateImport(c, tx, in); ok { c.JSON(200, out) } } func (s *Server) commitImport(c *gin.Context) { var in importInput if !s.decode(c, &in) { return } key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) if key == "" || len(key) > 100 || len(in.PreviewToken) != 64 { s.fail(c, 400, "invalid_request", "请先完成预检,并携带有效请求标识") return } raw, _ := json.Marshal([]string{in.Kind, in.CSV}) hash := store.Hash(string(raw)) ctx := c.Request.Context() actor := s.admin(c) tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if !s.lockAuthorization(c, tx) { return } var previous struct { Hash string `db:"request_hash"` Result []byte `db:"result"` } err = tx.GetContext(ctx, &previous, "SELECT request_hash,result FROM import_batches WHERE actor_id=$1 AND idempotency_key=$2", actor.ID, key) if err == nil { if hash != previous.Hash { s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他导入内容") return } var out importResult if err = json.Unmarshal(previous.Result, &out); err != nil { s.dbError(c, err) return } out.Replayed = true c.JSON(200, out) return } if err != sql.ErrNoRows { s.dbError(c, err) return } err = nil preview, ok := s.validateImport(c, tx, in) if !ok { return } if preview.ErrorRows > 0 || !hmac.Equal([]byte(preview.PreviewToken), []byte(in.PreviewToken)) { s.fail(c, 409, "import_changed", "数据或授权状态已变化,请重新预检;本次没有导入任何行") return } out := importResult{ID: store.ID("import_"), Kind: in.Kind, Count: len(preview.Rows), Items: []importItem{}, ActivationSecrets: []activationItem{}} for _, row := range preview.Rows { id := row.userID switch in.Kind { case "users": id = store.ID("user_") _, err = tx.ExecContext(ctx, "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, row.Key, row.Values["displayName"]) case "terminals": id = store.ID("term_") secret := store.ID("activate_") _, err = tx.ExecContext(ctx, "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, row.Key, row.Values["name"], row.Values["group"], store.Hash(secret)) out.ActivationSecrets = append(out.ActivationSecrets, activationItem{row.Key, secret}) case "bindings": if row.terminalID != "" { _, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1 WHERE id=$2", row.terminalID, row.userID) } if err == nil && len(row.desktops) > 0 { _, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", row.userID, row.desktops) } if err == nil { _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", row.userID) } } if conflict(err) { s.fail(c, 409, "import_changed", "数据已变化,请重新预检;本次没有导入任何行") return } if err != nil { s.dbError(c, err) return } out.Items = append(out.Items, importItem{row.Line, row.Key, id}) } // Keep the reusable result, never plaintext activation credentials or source CSV. safe := out safe.ActivationSecrets = []activationItem{} result, _ := json.Marshal(safe) _, err = tx.ExecContext(ctx, "INSERT INTO import_batches(id,actor_id,kind,row_count,idempotency_key,request_hash,result) VALUES($1,$2,$3,$4,$5,$6,$7)", out.ID, actor.ID, in.Kind, out.Count, key, hash, string(result)) if err == nil { err = accountAudit(ctx, tx, actor.Username, "import_"+in.Kind, out.ID, c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(200, out) }