账号与团队: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→云端编辑自动同步→本地快照隔离)
This commit is contained in:
1 parent
045e8c65c3
commit
aabd20bd75
18 files changed
+1338
-38
No files matched your search
@@ -0,0 +1,238 @@
|
||||
// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"opsxapi/server/internal/store"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func New(path string) (*API, error) {
|
||||
s, err := store.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &API{store: s}, nil
|
||||
}
|
||||
|
||||
func (a *API) Register(r *gin.Engine) {
|
||||
g := r.Group("/api")
|
||||
g.POST("/auth/register", a.handleRegister)
|
||||
g.POST("/auth/login", a.handleLogin)
|
||||
g.POST("/auth/logout", a.auth(a.handleLogout))
|
||||
g.GET("/me", a.auth(a.handleMe))
|
||||
g.POST("/teams", a.auth(a.handleCreateTeam))
|
||||
g.POST("/teams/:id/members", a.auth(a.handleAddMember))
|
||||
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
|
||||
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
|
||||
}
|
||||
|
||||
/* ---------------- 中间件 ---------------- */
|
||||
|
||||
func (a *API) auth(fn func(c *gin.Context, user *store.User)) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
token := strings.TrimSpace(strings.TrimPrefix(h, "Bearer"))
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
user, err := a.store.UserByToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"})
|
||||
return
|
||||
}
|
||||
c.Set("token", token)
|
||||
fn(c, user)
|
||||
}
|
||||
}
|
||||
|
||||
func userID(c *gin.Context) int64 { return c.GetInt64("uid") }
|
||||
|
||||
/* ---------------- 鉴权 ---------------- */
|
||||
|
||||
func (a *API) handleRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写有效邮箱与至少 6 位密码"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = strings.SplitN(req.Email, "@", 2)[0]
|
||||
}
|
||||
if _, err := a.store.UserByEmail(strings.ToLower(req.Email)); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"})
|
||||
return
|
||||
}
|
||||
user, err := a.store.CreateUser(name, strings.ToLower(req.Email), req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
token, err := a.store.IssueToken(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
func (a *API) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱与密码"})
|
||||
return
|
||||
}
|
||||
user, err := a.store.CheckPassword(strings.ToLower(req.Email), req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码不正确"})
|
||||
return
|
||||
}
|
||||
token, err := a.store.IssueToken(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
func (a *API) handleLogout(c *gin.Context, _ *store.User) {
|
||||
if token, _ := c.Get("token"); token != nil {
|
||||
_ = a.store.DeleteToken(token.(string))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
/* ---------------- 账号概览 ---------------- */
|
||||
|
||||
func (a *API) handleMe(c *gin.Context, user *store.User) {
|
||||
teams, err := a.store.TeamsOfUser(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
spaces, err := a.store.WorkspacesOfUser(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// data 不随列表下发
|
||||
for i := range spaces {
|
||||
spaces[i].Data = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user, "teams": teams, "workspaces": spaces, "serverTime": time.Now().Unix()})
|
||||
}
|
||||
|
||||
/* ---------------- 团队 ---------------- */
|
||||
|
||||
func (a *API) handleCreateTeam(c *gin.Context, user *store.User) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=40"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写团队名称"})
|
||||
return
|
||||
}
|
||||
team, err := a.store.CreateTeam(user.ID, strings.TrimSpace(req.Name))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (a *API) handleAddMember(c *gin.Context, user *store.User) {
|
||||
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
|
||||
return
|
||||
}
|
||||
owner, err := a.store.IsTeamOwner(teamID, user.ID)
|
||||
if err != nil || !owner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可添加成员"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"})
|
||||
return
|
||||
}
|
||||
member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
|
||||
}
|
||||
|
||||
/* ---------------- 工作区同步 ---------------- */
|
||||
|
||||
func (a *API) workspaceOf(c *gin.Context, user *store.User) *store.Workspace {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的空间"})
|
||||
return nil
|
||||
}
|
||||
ok, err := a.store.CanAccessWorkspace(id, user.ID)
|
||||
if err != nil || !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该空间"})
|
||||
return nil
|
||||
}
|
||||
ws, err := a.store.WorkspaceByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "空间不存在"})
|
||||
return nil
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
func (a *API) handleGetWorkspace(c *gin.Context, user *store.User) {
|
||||
ws := a.workspaceOf(c, user)
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ws)
|
||||
}
|
||||
|
||||
func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) {
|
||||
ws := a.workspaceOf(c, user)
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Data string `json:"data" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少数据体"})
|
||||
return
|
||||
}
|
||||
if len(req.Data) > 20*1024*1024 {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "工作区数据超过 20MB 上限"})
|
||||
return
|
||||
}
|
||||
now, err := a.store.SaveWorkspace(ws.ID, req.Data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updatedAt": now})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 账号与团队
|
||||
|
||||
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`apps/server/data/opsxapi.db`),不上传任何第三方。
|
||||
|
||||
## 注册 / 登录
|
||||
|
||||
顶栏右上 **登录** 按钮打开弹窗:
|
||||
|
||||
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
|
||||
- 登录态保存在本机,令牌失效会自动清除。
|
||||
|
||||
## 空间切换
|
||||
|
||||
登录后顶栏出现 **用户胶囊**(头像首字母 + 当前空间名 + 同步状态),点击弹出空间列表:
|
||||
|
||||
| 空间 | 说明 |
|
||||
| --- | --- |
|
||||
| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 |
|
||||
| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 |
|
||||
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
|
||||
|
||||
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
|
||||
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),胶囊显示「已同步 / 同步中 / 同步失败」;
|
||||
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
|
||||
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
|
||||
|
||||
## 团队管理
|
||||
|
||||
用户胶囊旁 **⋯** 菜单 → 团队管理:
|
||||
|
||||
- **新建团队**:创建后自动获得同名共享空间;
|
||||
- **添加成员**:输入已注册成员邮箱(创建者可操作),成员登录后即可看到并切换到该团队空间。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
|
||||
- 云端空间单份数据上限 20MB;
|
||||
- 团队空间权限:全体成员可读写,仅创建者可管理成员;
|
||||
- 不登录不影响任何本地功能。
|
||||
@@ -123,6 +123,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "账号与团队",
|
||||
"pages": [
|
||||
{
|
||||
"file": "20-account.md",
|
||||
"slug": "account",
|
||||
"title": "账号与团队",
|
||||
"updated": "2026-09-17"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "参考",
|
||||
"pages": [
|
||||
|
||||
@@ -2,10 +2,13 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/api"
|
||||
"opsxapi/server/internal/docs"
|
||||
"opsxapi/server/internal/echo"
|
||||
"opsxapi/server/internal/proxy"
|
||||
"opsxapi/server/internal/site"
|
||||
)
|
||||
@@ -27,6 +30,14 @@ func NewRouter() *gin.Engine {
|
||||
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
|
||||
r.POST("/v1/proxy/ws/close", tunnel.Close)
|
||||
|
||||
// 账号 / 团队 / 云端空间(SQLite)
|
||||
accountAPI, err := api.New(os.Getenv("OPSXAPI_DB"))
|
||||
if err != nil {
|
||||
panic("init sqlite store: " + err.Error())
|
||||
}
|
||||
accountAPI.Register(r)
|
||||
|
||||
echo.Mount(r)
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
|
||||
@@ -47,7 +58,7 @@ func localhostCORS() gin.HandlerFunc {
|
||||
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user