Files
opsxapi/apps/server/internal/store/store.go
T
logan aabd20bd75 账号与团队:SQLite 云端空间(个人 / 团队共享 + 自动同步)
- 服务端(Gin + modernc.org/sqlite,data/opsxapi.db WAL):
  - users/tokens/teams/team_members/workspaces 表;注册登录(加盐哈希 + Bearer 令牌)
  - /api:auth register/login/logout、me(用户+团队+空间)、teams CRUD/邀请成员、workspaces 读写(20MB 上限,成员鉴权)
- 前端:
  - account.ts 客户端 + store 账号态:登录/注册/登出/团队/空间切换
  - 顶栏账号区:未登录「登录」按钮;登录后用户胶囊(头像+空间名+已同步/同步中/失败)+ 空间切换菜单 + ⋯(立即同步/团队管理/退出)
  - 空间模型:本地(localStorage)与云端(SQLite)隔离,切云端空空间自动播种本地数据;云端改动 1.5s 去抖自动同步;切回本地恢复快照
  - 登录/注册弹窗 + 团队管理弹窗(建团队、邮箱邀请);vite 代理 /api;docs 新增「账号与团队」页
- 验证:curl E2E(注册/冲突 409/错误密码 401/团队共享写/越权 401)+ 浏览器全链路(注册→播种上云 17KB→云端编辑自动同步→本地快照隔离)
2026-09-18 11:42:34 +08:00

341 lines
9.5 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"
"time"
_ "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 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
}
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
}