diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index ed86f6a..a4eb4f2 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -286,6 +286,99 @@ func (h *Handler) SaveBillingConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } +// AdminTenants: GET /api/v1/admin/tenants —— 全平台租户目录(含成员数 + 余额)。 +func (h *Handler) AdminTenants(c *gin.Context) { + rows, err := h.db.ListTenants(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"tenants": rows}) +} + +// AdminCreateTenant: POST /api/v1/admin/tenants {name, slug, owner_email?} —— 新建租户(可选指定 owner)。 +func (h *Handler) AdminCreateTenant(c *gin.Context) { + var b struct { + Name string `json:"name"` + Slug string `json:"slug"` + Plan string `json:"plan"` + OwnerEmail string `json:"owner_email"` + } + if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" || strings.TrimSpace(b.Slug) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name 和 slug 必填"}) + return + } + ctx := c.Request.Context() + t, err := h.db.CreateTenant(ctx, b.Name, b.Slug, b.Plan) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if em := strings.TrimSpace(b.OwnerEmail); em != "" { + if _, err := h.db.AddMemberByEmail(ctx, t.ID, em, store.RoleOwner); err != nil { + c.JSON(http.StatusOK, gin.H{"tenant": t, "warn": "租户已建,但指定 owner 失败:" + err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"tenant": t}) +} + +// AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。 +func (h *Handler) AdminMembers(c *gin.Context) { + rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"members": rows}) +} + +// AdminAddMember: POST /api/v1/admin/tenants/:id/members {email, role} —— 按邮箱加成员。 +func (h *Handler) AdminAddMember(c *gin.Context) { + var b struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Email) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"}) + return + } + if b.Role == "" { + b.Role = store.RoleMember + } + m, err := h.db.AddMemberByEmail(c.Request.Context(), c.Param("id"), strings.TrimSpace(b.Email), b.Role) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"member": m}) +} + +// AdminSetMemberRole: PUT /api/v1/admin/tenants/:id/members/:uid {role} —— 改成员角色。 +func (h *Handler) AdminSetMemberRole(c *gin.Context) { + var b struct { + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&b); err != nil || b.Role == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "role 必填"}) + return + } + if err := h.db.SetMemberRole(c.Request.Context(), c.Param("id"), c.Param("uid"), b.Role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// AdminRemoveMember: DELETE /api/v1/admin/tenants/:id/members/:uid —— 软移除成员。 +func (h *Handler) AdminRemoveMember(c *gin.Context) { + if err := h.db.RemoveMember(c.Request.Context(), c.Param("id"), c.Param("uid")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + // GrantCredits: POST /api/v1/admin/credits/grant —— 给租户充值/发放积分(记账本 + 增余额)。 func (h *Handler) GrantCredits(c *gin.Context) { var b struct { diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index a0afa10..0ce61df 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -102,6 +102,13 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关) admin.PUT("/billing-config", h.SaveBillingConfig) admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分 + // 多租户成员管理(平台运维口径) + admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) + admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) + admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表 + admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员 + admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色 + admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 diff --git a/sundynix-gateway/internal/store/tenant.go b/sundynix-gateway/internal/store/tenant.go index 5fe8ecf..cd6dd6b 100644 --- a/sundynix-gateway/internal/store/tenant.go +++ b/sundynix-gateway/internal/store/tenant.go @@ -2,11 +2,23 @@ package store import ( "context" + "errors" + "strings" + "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) +// ValidRole 校验角色取值合法。 +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember, RoleViewer, RoleBillingAdmin: + return true + } + return false +} + // 角色/成员状态常量。 const ( RoleOwner = "owner" @@ -161,3 +173,124 @@ func firstNonEmpty(a, b string) string { } return b } + +// ---- 成员管理(多成员租户)---- + +// MemberInfo 是一条成员视图(join user 拿邮箱/名字)。 +type MemberInfo struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + JoinedAt time.Time `json:"joined_at"` +} + +// ListMembers 返回某租户的成员(含邮箱/名字/角色),排除已移除。 +func (p *Postgres) ListMembers(ctx context.Context, tenantID string) ([]MemberInfo, error) { + if p.db == nil { + return nil, nil + } + var out []MemberInfo + err := p.db.WithContext(ctx).Table("sundynix_tenant_member m"). + Select("m.user_id, u.email, u.name, m.role, m.status, m.created_at as joined_at"). + Joins("JOIN sundynix_user u ON u.id = m.user_id"). + Where("m.tenant_id = ? AND m.status <> ?", tenantID, "removed"). + Order("m.created_at asc").Scan(&out).Error + return out, err +} + +// AddMemberByEmail 按邮箱把已注册用户加入租户(幂等:已在则改其角色并置 active,含"复活"已移除者)。 +// 用户不存在返回错误(本阶段不做外发邀请邮件,须对方先注册)。 +func (p *Postgres) AddMemberByEmail(ctx context.Context, tenantID, email, role string) (*MemberInfo, error) { + if p.db == nil { + return nil, errStoreDisabled + } + if !ValidRole(role) { + return nil, errors.New("非法角色") + } + // 注册时邮箱按小写存,这里也归一化,避免大小写导致查不到。 + u, err := p.GetUserByEmail(ctx, strings.ToLower(strings.TrimSpace(email))) + if errors.Is(err, gorm.ErrRecordNotFound) || u == nil { + return nil, errors.New("该邮箱尚未注册,请对方先注册后再加入") + } + if err != nil { + return nil, err + } + if err := p.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]any{"role": role, "status": "active", "updated_at": time.Now()}), + }).Create(&TenantMember{TenantID: tenantID, UserID: u.ID, Role: role, Status: "active"}).Error; err != nil { + return nil, err + } + return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil +} + +// SetMemberRole 改成员角色。不允许把 owner 降级(须先转让 owner,超出本阶段)。 +func (p *Postgres) SetMemberRole(ctx context.Context, tenantID, userID, role string) error { + if p.db == nil { + return errStoreDisabled + } + if !ValidRole(role) { + return errors.New("非法角色") + } + if p.MemberRole(ctx, tenantID, userID) == RoleOwner { + return errors.New("不能改 owner 的角色") + } + if role == RoleOwner { + return errors.New("不能直接授予 owner(须转让)") + } + return p.db.WithContext(ctx).Model(&TenantMember{}). + Where("tenant_id = ? AND user_id = ?", tenantID, userID).Update("role", role).Error +} + +// RemoveMember 软移除成员(status=removed)。不能移除 owner。 +func (p *Postgres) RemoveMember(ctx context.Context, tenantID, userID string) error { + if p.db == nil { + return errStoreDisabled + } + if p.MemberRole(ctx, tenantID, userID) == RoleOwner { + return errors.New("不能移除 owner") + } + return p.db.WithContext(ctx).Model(&TenantMember{}). + Where("tenant_id = ? AND user_id = ?", tenantID, userID).Update("status", "removed").Error +} + +// TenantInfo 是管理端租户目录一行(含成员数 + 余额)。 +type TenantInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Plan string `json:"plan"` + Status string `json:"status"` + CreditBalanceMicro int64 `json:"credit_balance_micro"` + Members int64 `json:"members"` +} + +// ListTenants 返回全部租户(管理端目录:含 active 成员数 + 积分余额)。 +func (p *Postgres) ListTenants(ctx context.Context) ([]TenantInfo, error) { + if p.db == nil { + return nil, nil + } + var out []TenantInfo + err := p.db.WithContext(ctx).Table("sundynix_tenant t"). + Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, "+ + "(SELECT count(*) FROM sundynix_tenant_member m WHERE m.tenant_id = t.id AND m.status = 'active') as members"). + Where("t.deleted_at IS NULL").Order("t.created_at asc").Scan(&out).Error + return out, err +} + +// MyTenants 返回某用户所属(active)的全部租户(供多租户切换)。 +func (p *Postgres) MyTenants(ctx context.Context, userID string) ([]TenantInfo, error) { + if p.db == nil { + return nil, nil + } + var out []TenantInfo + err := p.db.WithContext(ctx).Table("sundynix_tenant t"). + Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, "+ + "(SELECT count(*) FROM sundynix_tenant_member m2 WHERE m2.tenant_id = t.id AND m2.status = 'active') as members"). + Joins("JOIN sundynix_tenant_member m ON m.tenant_id = t.id"). + Where("m.user_id = ? AND m.status = 'active' AND t.deleted_at IS NULL", userID). + Order("t.created_at asc").Scan(&out).Error + return out, err +}