Files
opsxapi/apps/server/internal/store/store.go
T
logan 39f4d5addc 桌面应用图标替换为 B-brackets 设计(深藏青 + 白/蓝花括号)
- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成:
  icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo
- 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效
- 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
2026-09-20 00:14:32 +08:00

628 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package store:SQLite 轻量存储(账号 / 团队 / 云端工作区)。
// 纯 Go 驱动 modernc.org/sqlite,无需 cgo;库文件默认 ./data/opsxapi.db。
package store
import (
"crypto/rand"
"crypto/sha256"
"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 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 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
}