// Package store:SQLite 轻量存储(账号 / 团队 / 云端工作区)。 // 纯 Go 驱动 modernc.org/sqlite,无需 cgo;库文件默认 ./data/opsxapi.db。 package store import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "time" "opsxapi/server/internal/mocksrv" _ "modernc.org/sqlite" ) var ErrNotFound = errors.New("not found") type Store struct { db *sql.DB } func Open(path string) (*Store, error) { if path == "" { path = "data/opsxapi.db" } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") if err != nil { return nil, err } db.SetMaxOpenConns(1) // sqlite 单写者,避免锁竞争 s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS mock_service (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1); INSERT OR IGNORE INTO mock_service(id,enabled) VALUES(1,1); CREATE TABLE IF NOT EXISTS published_docs (project_id TEXT PRIMARY KEY, name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, content TEXT NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, pass_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS tokens ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS teams ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner_id INTEGER NOT NULL REFERENCES users(id), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS team_members ( team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', specialty TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, PRIMARY KEY (team_id, user_id) ); CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id), data TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL, mock_token TEXT UNIQUE ); CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id); CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id); CREATE TABLE IF NOT EXISTS mock_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, method TEXT NOT NULL, path TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 200, content_type TEXT NOT NULL DEFAULT 'application/json', body TEXT NOT NULL DEFAULT '', delay_ms INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, UNIQUE(workspace_id, method, path) ); CREATE INDEX IF NOT EXISTS idx_mock_ws ON mock_endpoints(workspace_id); CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); `) if err != nil { return err } // 老库补列 + 回填随机 token(幂等) if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil { _, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`) } _, _ = s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_expires_at INTEGER NOT NULL DEFAULT 0`) _, _ = s.db.Exec(`ALTER TABLE mock_service ADD COLUMN require_bearer INTEGER NOT NULL DEFAULT 0`) _, _ = s.db.Exec(`ALTER TABLE mock_service ADD COLUMN bearer_hash TEXT NOT NULL DEFAULT ''`) // 老库补擅长列(幂等) _, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`) rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`) if err != nil { return err } var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { rows.Close() return err } ids = append(ids, id) } rows.Close() for _, id := range ids { if _, err := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, newMockToken(), id); err != nil { return err } } return nil } // newMockToken 16 位随机 hex(URL 片段,不可枚举) func newMockToken() string { raw := make([]byte, 8) _, _ = rand.Read(raw) return hex.EncodeToString(raw) } /* ---------------- 密码 ---------------- */ func hashPassword(password string) string { salt := make([]byte, 12) _, _ = rand.Read(salt) sum := sha256.Sum256(append(salt, []byte(password)...)) return hex.EncodeToString(salt) + "$" + hex.EncodeToString(sum[:]) } func verifyPassword(password, stored string) bool { idx := -1 for i := 0; i < len(stored); i++ { if stored[i] == '$' { idx = i break } } if idx <= 0 { return false } salt, _ := hex.DecodeString(stored[:idx]) sum := sha256.Sum256(append(salt, []byte(password)...)) return hex.EncodeToString(sum[:]) == stored[idx+1:] } /* ---------------- 用户 / 令牌 ---------------- */ type User struct { ID int64 `json:"id"` Name string `json:"name"` Email string `json:"email"` } func (s *Store) CreateUser(name, email, password string) (*User, error) { res, err := s.db.Exec( `INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`, name, email, hashPassword(password), time.Now().Unix(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() // 个人空间随账号创建 if _, err := s.db.Exec( `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`, "我的空间", id, time.Now().Unix(), ); err != nil { return nil, err } return &User{ID: id, Name: name, Email: email}, nil } func (s *Store) UserByEmail(email string) (*User, error) { u := &User{} err := s.db.QueryRow(`SELECT id, name, email FROM users WHERE email = ?`, email). Scan(&u.ID, &u.Name, &u.Email) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return u, err } func (s *Store) CheckPassword(email, password string) (*User, error) { var hash string u := &User{} err := s.db.QueryRow(`SELECT id, name, email, pass_hash FROM users WHERE email = ?`, email). Scan(&u.ID, &u.Name, &u.Email, &hash) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if !verifyPassword(password, hash) { return nil, ErrNotFound } return u, nil } func (s *Store) IssueToken(userID int64) (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := hex.EncodeToString(raw) _, err := s.db.Exec(`INSERT INTO tokens(token, user_id, created_at) VALUES(?,?,?)`, token, userID, time.Now().Unix()) return token, err } func (s *Store) UserByToken(token string) (*User, error) { u := &User{} err := s.db.QueryRow( `SELECT u.id, u.name, u.email FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?`, token, ).Scan(&u.ID, &u.Name, &u.Email) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return u, err } func (s *Store) DeleteToken(token string) error { _, err := s.db.Exec(`DELETE FROM tokens WHERE token = ?`, token) return err } /* ---------------- 团队 ---------------- */ type Team struct { ID int64 `json:"id"` Name string `json:"name"` Role string `json:"role"` Owner string `json:"owner"` } func (s *Store) CreateTeam(ownerID int64, name string) (*Team, error) { now := time.Now().Unix() res, err := s.db.Exec(`INSERT INTO teams(name, owner_id, created_at) VALUES(?,?,?)`, name, ownerID, now) if err != nil { return nil, err } id, _ := res.LastInsertId() if _, err := s.db.Exec(`INSERT INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'owner',?)`, id, ownerID, now); err != nil { return nil, err } if _, err := s.db.Exec( `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,?,?,'',?,?)`, name, id, ownerID, now, newMockToken(), ); err != nil { return nil, err } return &Team{ID: id, Name: name, Role: "owner"}, nil } func (s *Store) TeamsOfUser(userID int64) ([]Team, error) { rows, err := s.db.Query(` SELECT t.id, t.name, m.role, IFNULL((SELECT u.name FROM users u WHERE u.id = t.owner_id), '') FROM team_members m JOIN teams t ON t.id = m.team_id WHERE m.user_id = ? ORDER BY t.id`, userID) if err != nil { return nil, err } defer rows.Close() var out []Team for rows.Next() { var t Team if err := rows.Scan(&t.ID, &t.Name, &t.Role, &t.Owner); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // TeamMember 团队成员(含擅长标签,多个以空白或顿号分隔) type TeamMember struct { UserID int64 `json:"userId"` Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` Specialty string `json:"specialty"` } // TeamMembers 团队成员列表:创建者在最前,其余按加入时间 func (s *Store) TeamMembers(teamID int64) ([]TeamMember, error) { rows, err := s.db.Query(` SELECT m.user_id, IFNULL(u.name, ''), IFNULL(u.email, ''), m.role, m.specialty FROM team_members m LEFT JOIN users u ON u.id = m.user_id WHERE m.team_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, m.created_at`, teamID) if err != nil { return nil, err } defer rows.Close() var out []TeamMember for rows.Next() { var m TeamMember if err := rows.Scan(&m.UserID, &m.Name, &m.Email, &m.Role, &m.Specialty); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // IsTeamMember 是否为团队成员(创建者亦为成员) func (s *Store) IsTeamMember(teamID, userID int64) (bool, error) { var one int err := s.db.QueryRow(`SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } // UpdateMemberSpecialty 更新成员擅长(须先确认调用者是创建者或本人) func (s *Store) UpdateMemberSpecialty(teamID, userID int64, specialty string) error { res, err := s.db.Exec(`UPDATE team_members SET specialty = ? WHERE team_id = ? AND user_id = ?`, specialty, teamID, userID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } func (s *Store) AddTeamMember(teamID int64, email string, specialty string) (*User, error) { u, err := s.UserByEmail(email) if err != nil { return nil, err } _, err = s.db.Exec( `INSERT OR IGNORE INTO team_members(team_id, user_id, role, specialty, created_at) VALUES(?,?,'member',?,?)`, teamID, u.ID, specialty, time.Now().Unix(), ) return u, err } func (s *Store) IsTeamOwner(teamID, userID int64) (bool, error) { var one int err := s.db.QueryRow(`SELECT 1 FROM teams WHERE id = ? AND owner_id = ?`, teamID, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } // RenameTeam 重命名团队(权限由 API 层校验) func (s *Store) RenameTeam(teamID int64, name string) error { res, err := s.db.Exec(`UPDATE teams SET name = ? WHERE id = ?`, name, teamID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // DeleteTeam 删除团队:成员 / 团队空间 / 其 Mock 接口随外键级联清理 func (s *Store) DeleteTeam(teamID int64) error { res, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } /* ---------------- 工作区(云端空间) ---------------- */ type Workspace struct { ID int64 `json:"id"` Name string `json:"name"` TeamID *int64 `json:"teamId"` OwnerID int64 `json:"ownerId"` Data string `json:"data"` UpdatedAt int64 `json:"updatedAt"` MockToken string `json:"mockToken"` } // WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐) func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) { // 确保个人空间存在(兼容老数据) var one int err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { if _, err := s.db.Exec( `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`, "我的空间", userID, time.Now().Unix(), newMockToken(), ); err != nil { return nil, err } } else if err != nil { return nil, err } rows, err := s.db.Query(` SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at, IFNULL(w.mock_token,'') FROM workspaces w WHERE w.owner_id = ? OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) ORDER BY w.team_id IS NOT NULL, w.id`, userID, userID) if err != nil { return nil, err } defer rows.Close() var out []Workspace for rows.Next() { var w Workspace if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken); err != nil { return nil, err } out = append(out, w) } return out, rows.Err() } // CanAccessWorkspace:本人个人空间或其所在团队的空间 func (s *Store) CanAccessWorkspace(wsID, userID int64) (bool, error) { var one int err := s.db.QueryRow(` SELECT 1 FROM workspaces w WHERE w.id = ? AND (w.owner_id = ? OR (w.team_id IS NOT NULL AND w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))`, wsID, userID, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) { w := &Workspace{} err := s.db.QueryRow( `SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at, IFNULL(mock_token,'') FROM workspaces WHERE id = ?`, wsID, ).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return w, err } /* ---------------- Mock 接口 ---------------- */ // ReplaceMockEndpoints 全量替换某空间的 Mock 接口(发布即同步)。 func (s *Store) ReplaceMockEndpoints(workspaceID int64, list []mocksrv.Endpoint) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil { return err } now := time.Now().Unix() stmt, err := tx.Prepare(`INSERT OR REPLACE INTO mock_endpoints(workspace_id, method, path, status, content_type, body, delay_ms, updated_at) VALUES(?,?,?,?,?,?,?,?)`) if err != nil { return err } defer stmt.Close() for _, e := range list { if e.Path == "" { continue } if _, err := stmt.Exec(workspaceID, strings.ToUpper(e.Method), e.Path, e.Status, e.ContentType, e.Body, e.DelayMs, now); err != nil { return err } } return tx.Commit() } func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocksrv.Endpoint, bool, error) { e := &mocksrv.Endpoint{} err := s.db.QueryRow( `SELECT method, path, status, content_type, body, delay_ms FROM mock_endpoints WHERE workspace_id = ? AND method = ? AND path = ?`, workspaceID, strings.ToUpper(method), path, ).Scan(&e.Method, &e.Path, &e.Status, &e.ContentType, &e.Body, &e.DelayMs) if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } if err != nil { return nil, false, err } return e, true, nil } // ResolveMockToken:mock token → workspace id(不存在 / 已过期 / 已作废 均返回 false) func (s *Store) ResolveMockToken(token string) (int64, bool, error) { var id int64 var expiresAt int64 err := s.db.QueryRow( `SELECT id, IFNULL(mock_expires_at, 0) FROM workspaces WHERE mock_token = ? AND mock_token != ''`, token, ).Scan(&id, &expiresAt) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } if err != nil { return 0, false, err } if expiresAt > 0 && time.Now().Unix() >= expiresAt { return 0, false, nil } return id, true, nil } // SetMockExpiry 设置可选 TTL(expiresAtUnix=0 表示不过期)。 func (s *Store) SetMockExpiry(workspaceID int64, expiresAtUnix int64) error { _, err := s.db.Exec(`UPDATE workspaces SET mock_expires_at = ? WHERE id = ?`, expiresAtUnix, workspaceID) return err } // RevokeMock 作废当前 mock token:轮换新 token、清零过期、清空 endpoints;旧链接立刻失效。 func (s *Store) RevokeMock(workspaceID int64) (newToken string, err error) { newToken = newMockToken() tx, err := s.db.Begin() if err != nil { return "", err } defer tx.Rollback() if _, err := tx.Exec( `UPDATE workspaces SET mock_token = ?, mock_expires_at = 0 WHERE id = ?`, newToken, workspaceID, ); err != nil { return "", err } if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return newToken, nil } const localMockWorkspaceName = "__opsxapi_local_mock__" const localMockUserEmail = "__local_mock__@opsxapi.internal" // ensureLocalMockOwner 系统账号,仅作 FK 锚点,不出现在登录流程。 func (s *Store) ensureLocalMockOwner() (int64, error) { var id int64 err := s.db.QueryRow(`SELECT id FROM users WHERE email = ?`, localMockUserEmail).Scan(&id) if err == nil { return id, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, err } res, err := s.db.Exec( `INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`, "本机联调", localMockUserEmail, hashPassword(newMockToken()), time.Now().Unix(), ) if err != nil { return 0, err } return res.LastInsertId() } // EnsureLocalMockWorkspace 为本机联调预留的系统空间(不绑真实用户账号)。 func (s *Store) EnsureLocalMockWorkspace() (id int64, token string, err error) { err = s.db.QueryRow( `SELECT id, IFNULL(mock_token,'') FROM workspaces WHERE name = ? AND team_id IS NULL LIMIT 1`, localMockWorkspaceName, ).Scan(&id, &token) if err == nil { if token == "" { token = newMockToken() if _, e := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, token, id); e != nil { return 0, "", e } } return id, token, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, "", err } ownerID, err := s.ensureLocalMockOwner() if err != nil { return 0, "", err } token = newMockToken() now := time.Now().Unix() res, err := s.db.Exec( `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`, localMockWorkspaceName, ownerID, now, token, ) if err != nil { return 0, "", err } id, err = res.LastInsertId() return id, token, err } func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) { now := time.Now().Unix() _, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID) return now, err } // LocalMockSnapshot returns only this device's published mock data. func (s *Store) LocalMockSnapshot() ([]mocksrv.Endpoint, string, int64, error) { id, token, err := s.EnsureLocalMockWorkspace() if err != nil { return nil, "", 0, err } var expires int64 if err = s.db.QueryRow(`SELECT IFNULL(mock_expires_at,0) FROM workspaces WHERE id=?`, id).Scan(&expires); err != nil { return nil, "", 0, err } rows, err := s.db.Query(`SELECT method,path,status,content_type,body,delay_ms FROM mock_endpoints WHERE workspace_id=? ORDER BY path,method`, id) if err != nil { return nil, "", 0, err } defer rows.Close() endpoints := []mocksrv.Endpoint{} for rows.Next() { var e mocksrv.Endpoint if err = rows.Scan(&e.Method, &e.Path, &e.Status, &e.ContentType, &e.Body, &e.DelayMs); err != nil { return nil, "", 0, err } endpoints = append(endpoints, e) } return endpoints, token, expires, rows.Err() } func (s *Store) MockEnabled() (bool, error) { var enabled int err := s.db.QueryRow(`SELECT enabled FROM mock_service WHERE id=1`).Scan(&enabled) return enabled != 0, err } func (s *Store) SetMockEnabled(enabled bool) error { _, err := s.db.Exec(`UPDATE mock_service SET enabled=? WHERE id=1`, enabled) return err } // MockAccess returns only whether a bearer credential is configured, never its hash. func (s *Store) MockAccess() (required, configured bool, err error) { var enabled int var hash string err = s.db.QueryRow(`SELECT require_bearer,bearer_hash FROM mock_service WHERE id=1`).Scan(&enabled, &hash) return enabled != 0, hash != "", err } func (s *Store) SetMockAccess(enabled bool, token string) error { if !enabled { _, err := s.db.Exec(`UPDATE mock_service SET require_bearer=0,bearer_hash='' WHERE id=1`) return err } sum := sha256.Sum256([]byte(token)) _, err := s.db.Exec(`UPDATE mock_service SET require_bearer=1,bearer_hash=? WHERE id=1`, hex.EncodeToString(sum[:])) return err } func (s *Store) CheckMockBearer(token string) (bool, error) { var hash string if err := s.db.QueryRow(`SELECT bearer_hash FROM mock_service WHERE id=1`).Scan(&hash); err != nil { return false, err } if hash == "" || token == "" { return false, nil } sum := sha256.Sum256([]byte(token)) return subtle.ConstantTimeCompare([]byte(hash), []byte(hex.EncodeToString(sum[:]))) == 1, nil }