团队管理:成员擅长标签
- team_members 新增 specialty 列(老库自动 ALTER 迁移,幂等) - 新接口:GET /api/teams/:id/members 成员列表(仅成员可看)、PUT /api/teams/:id/members/:uid 更新擅长(创建者可改他人,成员仅改自己);邀请接口支持带擅长 - TeamDialog 重构:团队卡片内成员列表(头像/名称/邮箱/角色/彩色擅长 chip,超 3 个折叠 +N)、行内擅长编辑器(10 个预设点选 + 自定义输入)、邀请时顺手点选擅长预设 - 文档 20-account 同步更新(含活动栏新导航说明) - curl E2E:邀请带擅长/创建者改他人/自改/越权与非成员 403 全部通过;53 前端测试 + Go 测试全绿
This commit is contained in:
1 parent
6d72264793
commit
c6868b0ddd
9 files changed
+451
-48
No files matched your search
@@ -36,6 +36,8 @@ func (a *API) Register(r *gin.Engine) {
|
||||
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("/teams/:id/members", a.auth(a.handleListMembers))
|
||||
g.PUT("/teams/:id/members/:uid", a.auth(a.handleUpdateSpecialty))
|
||||
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
|
||||
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
|
||||
g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock))
|
||||
@@ -175,13 +177,14 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Specialty string `json:"specialty"`
|
||||
}
|
||||
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))
|
||||
member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email), trimSpecialty(req.Specialty))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"})
|
||||
return
|
||||
@@ -189,6 +192,71 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
|
||||
}
|
||||
|
||||
// handleListMembers 团队成员列表(创建者与成员均可查看)
|
||||
func (a *API) handleListMembers(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
|
||||
}
|
||||
member, err := a.store.IsTeamMember(teamID, user.ID)
|
||||
if err != nil || !member {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队成员可查看成员列表"})
|
||||
return
|
||||
}
|
||||
members, err := a.store.TeamMembers(teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||
}
|
||||
|
||||
// handleUpdateSpecialty 更新成员擅长:创建者可改任何人,成员只能改自己
|
||||
func (a *API) handleUpdateSpecialty(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
|
||||
}
|
||||
uid, err := strconv.ParseInt(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的成员"})
|
||||
return
|
||||
}
|
||||
owner, err := a.store.IsTeamOwner(teamID, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !owner && uid != user.ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可修改他人擅长,成员可修改自己的"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Specialty string `json:"specialty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := a.store.UpdateMemberSpecialty(teamID, uid, trimSpecialty(req.Specialty)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "该成员不在团队中"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// trimSpecialty 擅长标签去空白、压长度
|
||||
func trimSpecialty(s string) string {
|
||||
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||
r := []rune(s)
|
||||
if len(r) > 60 {
|
||||
return string(r[:60])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/* ---------------- Mock 发布 ---------------- */
|
||||
|
||||
func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
## 注册 / 登录
|
||||
|
||||
顶栏右上 **登录** 按钮打开弹窗:
|
||||
最左**活动栏**底部 **登录** 按钮打开弹窗:
|
||||
|
||||
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
|
||||
- 登录态保存在本机,令牌失效会自动清除。
|
||||
|
||||
## 空间切换
|
||||
|
||||
登录后顶栏出现 **用户胶囊**(头像首字母 + 当前空间名 + 同步状态),点击弹出空间列表:
|
||||
活动栏**顶部空间徽标**(未登录时为产品 Logo,云端空间显示彩色徽标)点击弹出空间列表:
|
||||
|
||||
| 空间 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -20,20 +20,21 @@
|
||||
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
|
||||
|
||||
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
|
||||
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),胶囊显示「已同步 / 同步中 / 同步失败」;
|
||||
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),活动栏底部头像上的状态点显示 已同步 / 同步中(闪烁)/ 同步失败(红);
|
||||
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
|
||||
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
|
||||
|
||||
## 团队管理
|
||||
|
||||
用户胶囊旁 **⋯** 菜单 → 团队管理:
|
||||
活动栏账号菜单或空间菜单 → **团队管理**:
|
||||
|
||||
- **新建团队**:创建后自动获得同名共享空间;
|
||||
- **添加成员**:输入已注册成员邮箱(创建者可操作),成员登录后即可看到并切换到该团队空间。
|
||||
- **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
|
||||
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
|
||||
- 云端空间单份数据上限 20MB;
|
||||
- 团队空间权限:全体成员可读写,仅创建者可管理成员;
|
||||
- 团队空间权限:全体成员可读写,仅创建者可管理成员与修改他人擅长;
|
||||
- 不登录不影响任何本地功能。
|
||||
@@ -71,6 +71,7 @@ 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)
|
||||
);
|
||||
@@ -107,6 +108,8 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
|
||||
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 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
|
||||
@@ -289,14 +292,68 @@ WHERE m.user_id = ? ORDER BY t.id`, userID)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddTeamMember(teamID int64, email string) (*User, error) {
|
||||
// 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, created_at) VALUES(?,?,'member',?)`,
|
||||
teamID, u.ID, time.Now().Unix(),
|
||||
`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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user