功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
songyiming committed 2026-11-10 17:52:52 +08:00
1 parent eef485306f
commit 6134d49c16
87 files changed
+29498 -126

No files matched your search

+388
View File
@@ -0,0 +1,388 @@
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)
}