身份自动发放
This commit is contained in:
parent
9b4447e158
commit
7cd08d0967
@ -135,6 +135,22 @@ func TestDeleteGiftOnlyCallsGiftConfigDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) {
|
||||||
|
router := newResourceHandlerTestRouter(New(&mockResourceWallet{}, nil, nil, time.Second, nil))
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/resource-groups/identity-auto-grant-config", nil)
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
// 这里故意使用 nil store,让配置 handler 返回 500;如果路由被 :group_id 抢走,会变成 400 的 ID 参数错误。
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("identity config route should hit config handler, got %d %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "ID 参数不正确") {
|
||||||
|
t.Fatalf("identity config route should not be parsed as group_id: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
|
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
@ -143,10 +159,30 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
|
|||||||
c.Set(middleware.ContextRequestID, "resource-handler-test")
|
c.Set(middleware.ContextRequestID, "resource-handler-test")
|
||||||
c.Set(middleware.ContextUserID, uint(7))
|
c.Set(middleware.ContextUserID, uint(7))
|
||||||
c.Set(middleware.ContextUsername, "tester")
|
c.Set(middleware.ContextUsername, "tester")
|
||||||
|
// 测试路由走真实 RegisterRoutes,权限一次性放开,避免中间件短路导致无法覆盖路由优先级。
|
||||||
|
c.Set(middleware.ContextPermissions, []string{
|
||||||
|
"emoji-pack:create",
|
||||||
|
"emoji-pack:view",
|
||||||
|
"gift:delete",
|
||||||
|
"gift:view",
|
||||||
|
"gift:update",
|
||||||
|
"gift:create",
|
||||||
|
"gift:status",
|
||||||
|
"resource:create",
|
||||||
|
"resource-grant:create",
|
||||||
|
"resource-grant:revoke",
|
||||||
|
"resource-grant:view",
|
||||||
|
"resource-group:create",
|
||||||
|
"resource-group:update",
|
||||||
|
"resource-group:view",
|
||||||
|
"resource-shop:update",
|
||||||
|
"resource-shop:view",
|
||||||
|
"resource:update",
|
||||||
|
"resource:view",
|
||||||
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
router.PUT("/admin/resources/mp4-layouts/batch", handler.UpdateMP4ResourceLayouts)
|
RegisterRoutes(router.Group(""), handler)
|
||||||
router.DELETE("/admin/gifts/:gift_id", handler.DeleteGift)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
304
server/admin/internal/modules/resource/identity_auto_grant.go
Normal file
304
server/admin/internal/modules/resource/identity_auto_grant.go
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
package resource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const resourceIdentityAutoGrantConfigGroup = "resource-identity-auto-grant-config"
|
||||||
|
|
||||||
|
var resourceIdentityAutoGrantTypes = []string{"host", "bd", "bd_leader", "agency", "manager"}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantConfigDTO struct {
|
||||||
|
Items []resourceIdentityAutoGrantItemDTO `json:"items"`
|
||||||
|
UpdatedByAdminID int64 `json:"updatedByAdminId"`
|
||||||
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantItemDTO struct {
|
||||||
|
IdentityType string `json:"identityType"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId"`
|
||||||
|
ResourceGroup *resourceGroupDTO `json:"resourceGroup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantConfigValue struct {
|
||||||
|
Items []resourceIdentityAutoGrantStoredItem `json:"items"`
|
||||||
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantStoredItem struct {
|
||||||
|
IdentityType string `json:"identity_type"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantConfigRequest struct {
|
||||||
|
Items []resourceIdentityAutoGrantItemRequest `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentityAutoGrantItemRequest struct {
|
||||||
|
IdentityType string `json:"identity_type"`
|
||||||
|
IdentityTypeCamel string `json:"identityType"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
ResourceGroupIDJS int64 `json:"resourceGroupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetResourceIdentityAutoGrantConfig(c *gin.Context) {
|
||||||
|
if h.store == nil {
|
||||||
|
response.ServerError(c, "后台配置存储未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 身份资源组属于 admin 后台低频配置,不进入 wallet 协议;读取时从 admin_app_configs 取当前 App 的快照。
|
||||||
|
config, err := h.loadIdentityAutoGrantConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "加载身份资源组配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateResourceIdentityAutoGrantConfig(c *gin.Context) {
|
||||||
|
if h.store == nil {
|
||||||
|
response.ServerError(c, "后台配置存储未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req resourceIdentityAutoGrantConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "身份资源组配置参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端保存的是整份配置;后端也要求 5 个身份一次性提交,避免半包请求把未提交身份误清空。
|
||||||
|
items, err := normalizeIdentityAutoGrantItems(req.Items)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resource_group_id=0 表示不自动发放;大于 0 必须先确认 wallet 中存在,防止保存悬空资源组。
|
||||||
|
if err := h.validateIdentityAutoGrantResourceGroups(c, items); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(resourceIdentityAutoGrantConfigValue{
|
||||||
|
Items: items,
|
||||||
|
UpdatedByAdminID: actorID(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "保存身份资源组配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appCode := appctx.FromContext(c.Request.Context())
|
||||||
|
// 配置按 App 隔离;Key 使用中间件解析出的 app_code,禁止请求体自行覆盖作用域。
|
||||||
|
item := model.AppConfig{
|
||||||
|
Group: resourceIdentityAutoGrantConfigGroup,
|
||||||
|
Key: appCode,
|
||||||
|
Value: string(payload),
|
||||||
|
Description: "用户身份自动发放资源组配置",
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertAppConfigs([]model.AppConfig{item}); err != nil {
|
||||||
|
response.ServerError(c, "保存身份资源组配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存后重新读取并补资源组详情,保证返回值和 GET 接口结构一致,前端无需自己拼保存后的展示数据。
|
||||||
|
config, err := h.loadIdentityAutoGrantConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "加载身份资源组配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditLog(c, "update-identity-auto-grant-config", "resource_identity_auto_grant_configs", appCode, "success", identityAutoGrantAuditDetail(items))
|
||||||
|
response.OK(c, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadIdentityAutoGrantConfig(c *gin.Context) (resourceIdentityAutoGrantConfigDTO, error) {
|
||||||
|
appCode := appctx.FromContext(c.Request.Context())
|
||||||
|
item, err := h.store.GetAppConfig(resourceIdentityAutoGrantConfigGroup, appCode)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// 首次进入页面没有配置行时返回完整身份集合,前端可以直接展示空配置而不是走错误态。
|
||||||
|
return resourceIdentityAutoGrantConfigDTO{Items: defaultIdentityAutoGrantItems()}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resourceIdentityAutoGrantConfigDTO{}, err
|
||||||
|
}
|
||||||
|
config, err := identityAutoGrantConfigFromValue(item.Value)
|
||||||
|
if err != nil {
|
||||||
|
return resourceIdentityAutoGrantConfigDTO{}, err
|
||||||
|
}
|
||||||
|
config.CreatedAtMS = item.CreatedAtMS
|
||||||
|
config.UpdatedAtMS = item.UpdatedAtMS
|
||||||
|
// 资源组详情只用于展示已选项名称;补全失败不应该让整个配置页打不开。
|
||||||
|
h.fillIdentityAutoGrantResourceGroups(c, config.Items)
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityAutoGrantConfigFromValue(raw string) (resourceIdentityAutoGrantConfigDTO, error) {
|
||||||
|
var value resourceIdentityAutoGrantConfigValue
|
||||||
|
if strings.TrimSpace(raw) != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||||
|
return resourceIdentityAutoGrantConfigDTO{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsByType := make(map[string]resourceIdentityAutoGrantStoredItem, len(value.Items))
|
||||||
|
for _, item := range value.Items {
|
||||||
|
identityType := strings.TrimSpace(item.IdentityType)
|
||||||
|
if isIdentityAutoGrantType(identityType) && item.ResourceGroupID >= 0 {
|
||||||
|
// 读取历史配置时按当前支持的身份集合过滤,旧脏数据不继续扩散到前端表单。
|
||||||
|
itemsByType[identityType] = resourceIdentityAutoGrantStoredItem{IdentityType: identityType, ResourceGroupID: item.ResourceGroupID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]resourceIdentityAutoGrantItemDTO, 0, len(resourceIdentityAutoGrantTypes))
|
||||||
|
for _, identityType := range resourceIdentityAutoGrantTypes {
|
||||||
|
// 输出顺序固定,避免前端每次打开弹窗时身份行抖动。
|
||||||
|
item := itemsByType[identityType]
|
||||||
|
items = append(items, resourceIdentityAutoGrantItemDTO{
|
||||||
|
IdentityType: identityType,
|
||||||
|
ResourceGroupID: item.ResourceGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resourceIdentityAutoGrantConfigDTO{Items: items, UpdatedByAdminID: value.UpdatedByAdminID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIdentityAutoGrantItems() []resourceIdentityAutoGrantItemDTO {
|
||||||
|
items := make([]resourceIdentityAutoGrantItemDTO, 0, len(resourceIdentityAutoGrantTypes))
|
||||||
|
for _, identityType := range resourceIdentityAutoGrantTypes {
|
||||||
|
items = append(items, resourceIdentityAutoGrantItemDTO{IdentityType: identityType})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIdentityAutoGrantItems(items []resourceIdentityAutoGrantItemRequest) ([]resourceIdentityAutoGrantStoredItem, error) {
|
||||||
|
if len(items) != len(resourceIdentityAutoGrantTypes) {
|
||||||
|
return nil, fmt.Errorf("必须配置所有身份")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemByType := make(map[string]resourceIdentityAutoGrantStoredItem, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
// 当前前端提交 snake_case;这里兼容 camelCase,避免旧构建或调试脚本因为字段名差异无法保存。
|
||||||
|
identityType := strings.TrimSpace(firstNonEmpty(item.IdentityType, item.IdentityTypeCamel))
|
||||||
|
if !isIdentityAutoGrantType(identityType) {
|
||||||
|
return nil, fmt.Errorf("身份参数不正确")
|
||||||
|
}
|
||||||
|
if _, exists := itemByType[identityType]; exists {
|
||||||
|
return nil, fmt.Errorf("身份不能重复配置")
|
||||||
|
}
|
||||||
|
// 0 是显式关闭自动发放,所以只拒绝负数;正数存在性在后续 wallet 查询里验证。
|
||||||
|
resourceGroupID := item.ResourceGroupID
|
||||||
|
if resourceGroupID == 0 {
|
||||||
|
resourceGroupID = item.ResourceGroupIDJS
|
||||||
|
}
|
||||||
|
if resourceGroupID < 0 {
|
||||||
|
return nil, fmt.Errorf("资源组必须是有效资源组")
|
||||||
|
}
|
||||||
|
itemByType[identityType] = resourceIdentityAutoGrantStoredItem{
|
||||||
|
IdentityType: identityType,
|
||||||
|
ResourceGroupID: resourceGroupID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make([]resourceIdentityAutoGrantStoredItem, 0, len(resourceIdentityAutoGrantTypes))
|
||||||
|
for _, identityType := range resourceIdentityAutoGrantTypes {
|
||||||
|
// 按白名单顺序重建数组,存储层不依赖前端传入顺序。
|
||||||
|
item, ok := itemByType[identityType]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("必须配置所有身份")
|
||||||
|
}
|
||||||
|
normalized = append(normalized, item)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) validateIdentityAutoGrantResourceGroups(c *gin.Context, items []resourceIdentityAutoGrantStoredItem) error {
|
||||||
|
if h.wallet == nil {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ResourceGroupID > 0 {
|
||||||
|
// 没有 wallet client 时只能接受全关闭配置,避免把未校验的正数资源组写入运行配置。
|
||||||
|
return fmt.Errorf("资源组服务未初始化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
checked := make(map[int64]struct{}, len(items))
|
||||||
|
ctx, cancel := h.walletRequestContext(c)
|
||||||
|
defer cancel()
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ResourceGroupID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := checked[item.ResourceGroupID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checked[item.ResourceGroupID] = struct{}{}
|
||||||
|
// 保存前只校验唯一资源组 ID,一份配置里多个身份共用同组时不重复打 wallet。
|
||||||
|
_, err := h.wallet.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
GroupId: item.ResourceGroupID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("资源组 %d 不存在或不可用", item.ResourceGroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) fillIdentityAutoGrantResourceGroups(c *gin.Context, items []resourceIdentityAutoGrantItemDTO) {
|
||||||
|
if h.wallet == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupByID := make(map[int64]*resourceGroupDTO, len(items))
|
||||||
|
ctx, cancel := h.walletRequestContext(c)
|
||||||
|
defer cancel()
|
||||||
|
for index := range items {
|
||||||
|
groupID := items[index].ResourceGroupID
|
||||||
|
if groupID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if group, ok := groupByID[groupID]; ok {
|
||||||
|
items[index].ResourceGroup = group
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 已保存配置可能指向被禁用或被删除的资源组;详情查询失败时保留 ID,让前端至少能展示 #ID 并允许重新选择。
|
||||||
|
resp, err := h.wallet.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
GroupId: groupID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group := resourceGroupFromProto(resp.GetGroup())
|
||||||
|
groupByID[groupID] = &group
|
||||||
|
items[index].ResourceGroup = &group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentityAutoGrantType(value string) bool {
|
||||||
|
for _, candidate := range resourceIdentityAutoGrantTypes {
|
||||||
|
if value == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityAutoGrantAuditDetail(items []resourceIdentityAutoGrantStoredItem) string {
|
||||||
|
parts := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%d", item.IdentityType, item.ResourceGroupID))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
@ -25,6 +25,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
|
|
||||||
protected.GET("/admin/resource-groups", middleware.RequirePermission("resource-group:view"), h.ListResourceGroups)
|
protected.GET("/admin/resource-groups", middleware.RequirePermission("resource-group:view"), h.ListResourceGroups)
|
||||||
protected.POST("/admin/resource-groups", middleware.RequirePermission("resource-group:create"), h.CreateResourceGroup)
|
protected.POST("/admin/resource-groups", middleware.RequirePermission("resource-group:create"), h.CreateResourceGroup)
|
||||||
|
protected.GET("/admin/resource-groups/identity-auto-grant-config", middleware.RequirePermission("resource-group:view"), h.GetResourceIdentityAutoGrantConfig)
|
||||||
|
protected.PUT("/admin/resource-groups/identity-auto-grant-config", middleware.RequirePermission("resource-group:update"), h.UpdateResourceIdentityAutoGrantConfig)
|
||||||
protected.GET("/admin/resource-groups/:group_id", middleware.RequirePermission("resource-group:view"), h.GetResourceGroup)
|
protected.GET("/admin/resource-groups/:group_id", middleware.RequirePermission("resource-group:view"), h.GetResourceGroup)
|
||||||
protected.PUT("/admin/resource-groups/:group_id", middleware.RequirePermission("resource-group:update"), h.UpdateResourceGroup)
|
protected.PUT("/admin/resource-groups/:group_id", middleware.RequirePermission("resource-group:update"), h.UpdateResourceGroup)
|
||||||
protected.PUT("/admin/resource-groups/:group_id/items", middleware.RequirePermission("resource-group:update"), h.UpdateResourceGroup)
|
protected.PUT("/admin/resource-groups/:group_id/items", middleware.RequirePermission("resource-group:update"), h.UpdateResourceGroup)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user