桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
1 parent
6452026fd6
commit
2bb4ef7a95
299 files changed
+6059
-3322
No files matched your search
@@ -0,0 +1,698 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user