2026-07-23 16:47:40 +08:00

330 lines
11 KiB
Go

package externaladmin
import (
"errors"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type externalInviteRequest struct {
TargetUserID FlexibleString `json:"targetUserId" binding:"required"`
ParentBDUserID FlexibleString `json:"parentBdUserId"`
AgencyID FlexibleString `json:"agencyId"`
AgencyName string `json:"agencyName"`
}
func (h *Handler) ListExternalTeamUsers(client userclient.ExternalTeamClient, fixedRole string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "团队服务暂不可用")
return
}
role := strings.ToLower(strings.TrimSpace(fixedRole))
if role == "" {
role = strings.ToLower(strings.TrimSpace(c.Query("role")))
}
if !externalTeamRoleAllowed(c, role) {
response.Forbidden(c, "没有该团队列表权限")
return
}
page := positiveQueryInt(c.Query("page"), 1)
pageSize := positiveQueryInt(c.Query("pageSize"), 20)
if pageSize > 100 {
pageSize = 100
}
result, err := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Role: role,
Status: strings.ToLower(strings.TrimSpace(c.DefaultQuery("status", "active"))),
Keyword: strings.TrimSpace(c.Query("keyword")), Page: page, PageSize: pageSize,
})
if err != nil {
writeExternalOwnerError(c, err, "获取团队用户失败")
return
}
if result == nil {
response.ServerError(c, "团队服务返回空结果")
return
}
response.OK(c, response.Page{Items: result.Users, Page: result.Page, PageSize: result.PageSize, Total: result.Total})
}
}
func (h *Handler) SearchExternalInvitationCandidate(client userclient.ExternalTeamClient) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "邀请服务暂不可用")
return
}
invitationType := strings.ToLower(strings.TrimSpace(c.Query("invitationType")))
if !externalInvitePermissionAllowed(c, invitationType) {
response.Forbidden(c, "没有该邀请权限")
return
}
item, err := client.SearchExternalInvitationCandidate(c.Request.Context(), userclient.SearchExternalInvitationCandidateRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external",
OperatorUserID: scope.LinkedAppUserID, ExpectedRegionID: scope.RegionID,
InvitationType: invitationType, Keyword: strings.TrimSpace(c.Query("keyword")),
})
if err != nil {
writeExternalOwnerError(c, err, "未找到符合条件的同区域用户")
return
}
response.OK(c, item)
}
}
func (h *Handler) CreateExternalBDInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "bd")
}
func (h *Handler) CreateExternalAgencyInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "agency")
}
func (h *Handler) CreateExternalHostInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "host")
}
func (h *Handler) createExternalInvitation(client userclient.ExternalTeamClient, invitationType string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "邀请服务暂不可用")
return
}
var request externalInviteRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "邀请参数不正确")
return
}
targetUserID, err := positiveFlexibleID(request.TargetUserID)
if err != nil {
response.BadRequest(c, "目标用户 ID 不正确")
return
}
requestID := middleware.CurrentRequestID(c)
commandID := fmt.Sprintf("external-%s:%s:%d", invitationType, requestID, targetUserID)
var invitation *userclient.ExternalRoleInvitation
switch invitationType {
case "bd":
invitation, err = client.ExternalInviteBD(c.Request.Context(), userclient.ExternalInviteBDRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, TargetUserID: targetUserID, ExpectedRegionID: scope.RegionID,
})
case "agency":
parentBDUserID, parseErr := positiveFlexibleID(request.ParentBDUserID)
if parseErr != nil || strings.TrimSpace(request.AgencyName) == "" {
response.BadRequest(c, "请选择团队 BD 并填写 Agency 名称")
return
}
invitation, err = client.ExternalInviteAgency(c.Request.Context(), userclient.ExternalInviteAgencyRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, ParentBDUserID: parentBDUserID, TargetUserID: targetUserID,
AgencyName: strings.TrimSpace(request.AgencyName), ScopeOwnerUserIDs: scope.OwnerUserIDs, ExpectedRegionID: scope.RegionID,
})
case "host":
agencyID, parseErr := positiveFlexibleID(request.AgencyID)
if parseErr != nil {
response.BadRequest(c, "请选择团队 Agency")
return
}
invitation, err = client.ExternalInviteHost(c.Request.Context(), userclient.ExternalInviteHostRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, AgencyID: agencyID, TargetUserID: targetUserID,
ScopeOwnerUserIDs: scope.OwnerUserIDs, ExpectedRegionID: scope.RegionID,
})
}
if err != nil {
writeExternalOwnerError(c, err, "创建邀请失败")
return
}
response.Created(c, invitation)
}
}
func (h *Handler) RequireExternalTeamTarget(client userclient.ExternalTeamClient, param string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
targetUserID, err := strconv.ParseInt(strings.TrimSpace(c.Param(param)), 10, 64)
if !ok || client == nil || err != nil || targetUserID <= 0 {
response.Forbidden(c, "目标用户不在当前团队")
c.Abort()
return
}
allowed := false
for _, userStatus := range []string{"active", "banned", "disabled"} {
result, lookupErr := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external-target-check",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Status: userStatus,
Keyword: strconv.FormatInt(targetUserID, 10), Page: 1, PageSize: 10,
})
if lookupErr != nil {
writeExternalOwnerError(c, lookupErr, "团队范围校验失败")
c.Abort()
return
}
for _, item := range result.Users {
if item.UserID == targetUserID && item.RegionID == scope.RegionID {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
response.Forbidden(c, "目标用户不在当前团队或区域")
c.Abort()
return
}
c.Next()
}
}
// HydrateExternalRoomScope resolves business team members before entering roomadmin.
// Room owners are BD/Agency/Host users, not portal Local/经理/SuperAdmin account roots.
func (h *Handler) HydrateExternalRoomScope(client userclient.ExternalTeamClient) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "团队服务暂不可用")
c.Abort()
return
}
const maxRoomScopeUsers = 5000
seen := make(map[int64]struct{})
teamUserIDs := make([]int64, 0)
for _, userStatus := range []string{"active", "banned", "disabled"} {
for page := 1; ; page++ {
result, err := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external-room-scope",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Status: userStatus,
Page: page, PageSize: 200,
})
if err != nil {
writeExternalOwnerError(c, err, "房间团队范围校验失败")
c.Abort()
return
}
if result == nil {
response.ServerError(c, "团队服务返回空结果")
c.Abort()
return
}
for _, item := range result.Users {
if item.UserID <= 0 || item.RegionID != scope.RegionID {
continue
}
if _, exists := seen[item.UserID]; exists {
continue
}
seen[item.UserID] = struct{}{}
teamUserIDs = append(teamUserIDs, item.UserID)
if len(teamUserIDs) > maxRoomScopeUsers {
response.Forbidden(c, "当前团队规模超过房间管理上限")
c.Abort()
return
}
}
if len(result.Users) == 0 || result.PageSize <= 0 || int64(page*result.PageSize) >= result.Total {
break
}
}
}
requestScope := appctx.ExternalScope{
AccountID: scope.AccountID, LinkedAppUserID: scope.LinkedAppUserID,
IdentityType: scope.IdentityType, RegionID: scope.RegionID, OwnerUserIDs: teamUserIDs,
}
c.Request = c.Request.WithContext(appctx.WithExternalScope(c.Request.Context(), requestScope))
c.Next()
}
}
func positiveFlexibleID(value FlexibleString) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(string(value)), 10, 64)
if err != nil || parsed <= 0 {
return 0, ErrInvalidInput
}
return parsed, nil
}
func positiveQueryInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
return value
}
func externalTeamRoleAllowed(c *gin.Context, role string) bool {
switch role {
case "":
return principalHasAnyPermission(c, "user:list", "user:update", "user:ban", "user-ban:list", "user:unban", "user-level:grant")
case "bd":
// Agency invitations must select an existing BD even when the account was
// intentionally granted agency:invite without the standalone BD list menu.
return principalHasAnyPermission(c, "bd:list", "bd:invite", "agency:invite")
case "agency":
// Host invitations have the same bounded parent-selector dependency on Agency.
return principalHasAnyPermission(c, "agency:list", "agency:invite", "host:invite")
case "host":
return principalHasAnyPermission(c, "host:list", "host:invite")
default:
return false
}
}
func externalInvitePermissionAllowed(c *gin.Context, invitationType string) bool {
return principalHasAnyPermission(c, invitationType+":invite")
}
func principalHasAnyPermission(c *gin.Context, permissions ...string) bool {
principal, ok := CurrentPrincipal(c)
if !ok {
return false
}
assigned := make(map[string]struct{}, len(principal.Permissions))
for _, permission := range principal.Permissions {
assigned[permission] = struct{}{}
}
for _, permission := range permissions {
if _, exists := assigned[permission]; exists {
return true
}
}
return false
}
func writeExternalOwnerError(c *gin.Context, err error, fallback string) {
if err == nil {
return
}
code := status.Code(err)
switch {
case code == codes.InvalidArgument:
response.BadRequest(c, status.Convert(err).Message())
case code == codes.NotFound:
response.NotFound(c, status.Convert(err).Message())
case code == codes.PermissionDenied || code == codes.FailedPrecondition:
response.Forbidden(c, status.Convert(err).Message())
case errors.Is(err, ErrInvalidInput):
response.BadRequest(c, fallback)
default:
response.ServerError(c, fallback)
}
}