Files
opsxapi/apps/server/internal/store/store.go
T
logan d9ccccd280 本地 Mock 服务 /mock + 限流 20QPS/60QPM + 服务条款
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型)
- mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个)
- 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404
- 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示
- 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规)
- UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试);
  发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE
- docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms
- 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
2026-09-18 12:29:49 +08:00

401 lines
11 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',
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
);
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);
`)
return err
}
/* ---------------- 密码 ---------------- */
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) VALUES(?,?,?,'',?)`,
name, id, ownerID, now,
); 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()
}
func (s *Store) AddTeamMember(teamID int64, email 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, created_at) VALUES(?,?,'member',?)`,
teamID, u.ID, 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
}
/* ---------------- 工作区(云端空间) ---------------- */
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"`
}
// 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) VALUES(?,NULL,?,'',?)`,
"我的空间", userID, time.Now().Unix(),
); 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
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); 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 FROM workspaces WHERE id = ?`, wsID,
).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt)
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
}
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
}