账号与团队: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})
|
||||
}
|
||||
Reference in new issue
Block a user