345 lines
15 KiB
Go

package externaladmin
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PermissionCatalogItem is the only assignable permission source for external accounts.
// Keeping route permissions, capability aliases and form metadata in one catalog prevents
// a client from persisting arbitrary main-admin permissions into an external session.
type PermissionCatalogItem struct {
Code string `json:"code"`
Label string `json:"label"`
Group string `json:"group"`
Dependencies []string `json:"dependencies"`
Capabilities []string `json:"capabilities"`
DefaultGranted bool `json:"defaultGranted"`
}
type AccountPermissionView struct {
AccountID uint64 `json:"accountId,string"`
Permissions []string `json:"permissions"`
Revision uint64 `json:"revision"`
}
type UpdatePermissionsInput struct {
Permissions []string
ExpectedRevision uint64
}
type PermissionUpdateResult struct {
Account AccountDTO
PreviousPermissions []string
Changed bool
SessionsRevoked bool
}
type PermissionValidationError struct {
Permission string
Dependency string
}
func (err *PermissionValidationError) Error() string {
if err.Dependency != "" {
return fmt.Sprintf("%s: %s requires %s", ErrInvalidPermissions, err.Permission, err.Dependency)
}
return fmt.Sprintf("%s: unsupported permission %s", ErrInvalidPermissions, err.Permission)
}
func (err *PermissionValidationError) Unwrap() error { return ErrInvalidPermissions }
var permissionCatalog = []PermissionCatalogItem{
{Code: "user:list", Label: "用户列表", Group: "用户管理", Capabilities: []string{"user:list"}, DefaultGranted: true},
{Code: "user:update", Label: "用户信息编辑", Group: "用户管理", Capabilities: []string{"user:update"}, DefaultGranted: true},
{Code: "user-ban:list", Label: "账号封禁列表", Group: "用户管理", Capabilities: []string{"user-ban:list"}, DefaultGranted: true},
{Code: "user:ban", Label: "执行封禁", Group: "用户管理", Capabilities: []string{"user:ban"}, DefaultGranted: true},
{Code: "user:unban", Label: "解除封禁", Group: "用户管理", Capabilities: []string{"user:unban"}, DefaultGranted: true},
{Code: "host:list", Label: "主播列表", Group: "组织管理", Capabilities: []string{"host:list"}, DefaultGranted: true},
{Code: "agency:list", Label: "公会列表", Group: "组织管理", Capabilities: []string{"agency:list"}, DefaultGranted: true},
{Code: "bd:list", Label: "BD 列表", Group: "组织管理", Capabilities: []string{"bd:list"}, DefaultGranted: true},
{Code: "bd-manager:list", Label: "BD Manager 列表", Group: "组织管理", Capabilities: []string{"bd-manager:list"}, DefaultGranted: true},
{Code: "super-admin:list", Label: "Super Admin 列表", Group: "组织管理", Capabilities: []string{"super-admin:list"}, DefaultGranted: true},
{Code: "team:view", Label: "我的团队", Group: "组织管理", Capabilities: []string{"team:view"}, DefaultGranted: true},
{Code: "room:list", Label: "房间管理", Group: "房间管理", Capabilities: []string{"room:list"}, DefaultGranted: true},
{Code: "room:update", Label: "房间编辑", Group: "房间管理", Capabilities: []string{"room:update"}, DefaultGranted: true},
{Code: "privilege:list", Label: "用户特权道具列表", Group: "资源与发放", Capabilities: []string{"privilege:list"}, DefaultGranted: true},
{Code: "privilege:grant", Label: "特权道具发放", Group: "资源与发放", Dependencies: []string{"user-title:grant", "room-background:grant"}, Capabilities: []string{"privilege:grant"}, DefaultGranted: true},
{Code: "pretty-id:grant", Label: "靓号下发", Group: "资源与发放", Capabilities: []string{"pretty-id:grant"}, DefaultGranted: true},
// Wallet resources currently have no authoritative user-title/room-background semantic type.
// Keep the three labels requested by the product, but make them one fail-closed assignment
// bundle until the owner model can classify them without guessing from names or URLs.
{Code: "user-title:grant", Label: "用户称号下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "room-background:grant"}, Capabilities: []string{"user-title:grant"}, DefaultGranted: true},
{Code: "room-background:grant", Label: "房间背景图下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "user-title:grant"}, Capabilities: []string{"room-background:grant"}, DefaultGranted: true},
{Code: "user-level:grant", Label: "财富/VIP等级下发", Group: "资源与发放", Capabilities: []string{"user-level:grant"}, DefaultGranted: true},
{Code: "banner:create", Label: "Banner创建", Group: "运营管理", Capabilities: []string{"banner:create"}, DefaultGranted: true},
}
// effectivePermissions upgrades the original 18 route-oriented snapshot into the
// 20 independently displayed business capabilities. New snapshots already contain
// catalog codes and pass through unchanged. Conditional legacy closures avoid turning
// a manually reduced old snapshot into broader Banner, VIP or grant access.
func effectivePermissions(stored []string) []string {
stored = normalizePermissions(stored)
storedSet := make(map[string]struct{}, len(stored))
for _, permission := range stored {
storedSet[permission] = struct{}{}
}
assignable := make(map[string]struct{}, len(permissionCatalog))
for _, item := range permissionCatalog {
assignable[item.Code] = struct{}{}
}
effective := make([]string, 0, len(permissionCatalog))
for _, permission := range stored {
if _, ok := assignable[permission]; ok {
effective = append(effective, permission)
}
}
legacyAliases := map[string][]string{
"app-user:view": {"user:list", "user-ban:list"},
"app-user:update": {"user:update"},
"app-user:status": {"user:ban", "user:unban"},
"host:view": {"host:list"},
"agency:view": {"agency:list"},
"bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"},
"room:view": {"room:list"},
"resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"},
}
for legacy, aliases := range legacyAliases {
if _, ok := storedSet[legacy]; ok {
effective = append(effective, aliases...)
}
}
if containsPermission(storedSet, "resource:view") && containsPermission(storedSet, "resource-grant:view") {
effective = append(effective, "privilege:list")
}
if containsPermission(storedSet, "app-user:level") && containsPermission(storedSet, "vip-config:grant") {
effective = append(effective, "user-level:grant")
}
if containsPermission(storedSet, "app-config:view") && containsPermission(storedSet, "app-config:update") && containsPermission(storedSet, "upload:create") {
effective = append(effective, "banner:create")
}
effective = normalizePermissions(effective)
// A hand-edited/corrupt new snapshot must not bypass the atomic grant bundle.
// Unknown codes and wildcard-looking values were already discarded above.
effectiveSet := make(map[string]struct{}, len(effective))
for _, permission := range effective {
effectiveSet[permission] = struct{}{}
}
grantBundle := []string{"privilege:grant", "user-title:grant", "room-background:grant"}
completeGrantBundle := true
for _, permission := range grantBundle {
if !containsPermission(effectiveSet, permission) {
completeGrantBundle = false
}
}
if !completeGrantBundle {
filtered := effective[:0]
for _, permission := range effective {
if permission != "privilege:grant" && permission != "user-title:grant" && permission != "room-background:grant" {
filtered = append(filtered, permission)
}
}
effective = filtered
}
return effective
}
func containsPermission(permissions map[string]struct{}, code string) bool {
_, ok := permissions[code]
return ok
}
func PermissionCatalog() []PermissionCatalogItem {
items := make([]PermissionCatalogItem, 0, len(permissionCatalog))
for _, item := range permissionCatalog {
item.Dependencies = append([]string(nil), item.Dependencies...)
item.Capabilities = append([]string(nil), item.Capabilities...)
items = append(items, item)
}
return items
}
func permissionCapabilityAliases() map[string][]string {
aliases := make(map[string][]string, len(permissionCatalog))
for _, item := range permissionCatalog {
aliases[item.Code] = item.Capabilities
}
return aliases
}
// validatePermissionSelection normalizes duplicates/order only after checking every
// code against the fixed catalog and every write dependency against the same snapshot.
// An explicit empty list is valid and represents an account with no business access.
func validatePermissionSelection(permissions []string) ([]string, error) {
normalized := normalizePermissions(permissions)
selected := make(map[string]struct{}, len(normalized))
assignable := make(map[string]PermissionCatalogItem, len(permissionCatalog))
for _, item := range permissionCatalog {
assignable[item.Code] = item
}
for _, permission := range normalized {
if _, ok := assignable[permission]; !ok {
return nil, &PermissionValidationError{Permission: permission}
}
selected[permission] = struct{}{}
}
for _, permission := range normalized {
for _, dependency := range assignable[permission].Dependencies {
if _, ok := selected[dependency]; !ok {
return nil, &PermissionValidationError{Permission: permission, Dependency: dependency}
}
}
}
return normalized, nil
}
// createPermissionSnapshot intentionally keeps omitted and explicit-empty requests
// distinct. Existing credential-creation clients omit the field and retain the full
// legacy snapshot; a permission-aware client may submit [] to create a locked-down
// account that can authenticate but cannot enter any business page.
func createPermissionSnapshot(input CreateInput) ([]string, error) {
if !input.PermissionsSet {
return DefaultPermissionSnapshot(), nil
}
return validatePermissionSelection(input.Permissions)
}
func (s *Service) GetAccountPermissions(ctx context.Context, appCode string, id uint64) (AccountPermissionView, error) {
appCode = normalizeAppCode(appCode)
if s.db == nil {
return AccountPermissionView{}, errors.New("admin mysql is not configured")
}
if appCode == "" || id == 0 {
return AccountPermissionView{}, ErrInvalidInput
}
var account model.ExternalAdminAccount
err := s.db.WithContext(ctx).Select("id", "permissions_json", "permission_revision").Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return AccountPermissionView{}, ErrAccountNotFound
}
if err != nil {
return AccountPermissionView{}, err
}
permissions, err := decodePermissions(account.PermissionsJSON)
if err != nil {
return AccountPermissionView{}, err
}
return AccountPermissionView{AccountID: account.ID, Permissions: permissions, Revision: account.PermissionRevision}, nil
}
func (s *Service) UpdateAccountPermissions(ctx context.Context, appCode string, id uint64, input UpdatePermissionsInput) (PermissionUpdateResult, error) {
appCode = normalizeAppCode(appCode)
if s.db == nil {
return PermissionUpdateResult{}, errors.New("admin mysql is not configured")
}
if appCode == "" || id == 0 || input.ExpectedRevision == 0 {
return PermissionUpdateResult{}, ErrInvalidInput
}
normalized, err := validatePermissionSelection(input.Permissions)
if err != nil {
return PermissionUpdateResult{}, err
}
encoded, err := encodePermissions(normalized)
if err != nil {
return PermissionUpdateResult{}, err
}
result := PermissionUpdateResult{}
var account model.ExternalAdminAccount
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// The account row lock serializes concurrent permission writers and makes the
// old/new audit snapshot match the exact value replaced by this transaction.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return err
}
if account.PermissionRevision != input.ExpectedRevision {
return ErrPermissionConflict
}
previous, err := decodePermissions(account.PermissionsJSON)
if err != nil {
return err
}
result.PreviousPermissions = previous
if reflect.DeepEqual(previous, normalized) {
return nil
}
if err := tx.Model(&account).Updates(map[string]any{
"permissions_json": encoded,
"permission_revision": account.PermissionRevision + 1,
}).Error; err != nil {
return err
}
// Revocation is committed atomically with the snapshot. No browser can keep a
// still-valid session after a permission reduction or silently retain a broader UI.
if err := revokeAllSessions(tx, account.ID, "permissions_changed", time.Now().UTC().UnixMilli()); err != nil {
return err
}
result.Changed = true
result.SessionsRevoked = true
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return PermissionUpdateResult{}, ErrAccountNotFound
}
if errors.Is(err, ErrPermissionConflict) {
return PermissionUpdateResult{}, ErrPermissionConflict
}
if err != nil {
return PermissionUpdateResult{}, err
}
// Clear the transaction-loaded primary key before reloading. Otherwise GORM adds a
// redundant implicit id predicate, which hides the intended App-scope contract and
// makes future key changes harder to reason about.
account = model.ExternalAdminAccount{}
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return PermissionUpdateResult{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
if err != nil {
return PermissionUpdateResult{}, err
}
result.Account = accountDTO(account, users[account.LinkedAppUserID])
return result, nil
}
func permissionValidationMessage(err error) string {
var validationErr *PermissionValidationError
if !errors.As(err, &validationErr) {
return "外管权限配置不正确"
}
permissionLabel, permissionKnown := permissionCatalogLabel(validationErr.Permission)
if !permissionKnown {
// Unsupported raw codes are implementation details and may contain attacker-
// controlled input. Keep the client error stable without reflecting that value.
return "外管权限中包含不支持的权限"
}
if validationErr.Dependency != "" {
dependencyLabel, dependencyKnown := permissionCatalogLabel(validationErr.Dependency)
if !dependencyKnown {
return "外管权限配置不正确"
}
return fmt.Sprintf("权限“%s”需同时选择“%s”", permissionLabel, dependencyLabel)
}
return "外管权限配置不正确"
}
func permissionCatalogLabel(code string) (string, bool) {
code = strings.TrimSpace(code)
for _, item := range permissionCatalog {
if item.Code == code {
return item.Label, true
}
}
return "", false
}