diff --git a/server/admin/internal/modules/resource/handler_test.go b/server/admin/internal/modules/resource/handler_test.go index 3ab62ef6..290e89ed 100644 --- a/server/admin/internal/modules/resource/handler_test.go +++ b/server/admin/internal/modules/resource/handler_test.go @@ -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 { gin.SetMode(gin.TestMode) router := gin.New() @@ -143,10 +159,30 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine { c.Set(middleware.ContextRequestID, "resource-handler-test") c.Set(middleware.ContextUserID, uint(7)) 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() }) - router.PUT("/admin/resources/mp4-layouts/batch", handler.UpdateMP4ResourceLayouts) - router.DELETE("/admin/gifts/:gift_id", handler.DeleteGift) + RegisterRoutes(router.Group(""), handler) return router } diff --git a/server/admin/internal/modules/resource/identity_auto_grant.go b/server/admin/internal/modules/resource/identity_auto_grant.go new file mode 100644 index 00000000..0ff887b5 --- /dev/null +++ b/server/admin/internal/modules/resource/identity_auto_grant.go @@ -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, ",") +} diff --git a/server/admin/internal/modules/resource/routes.go b/server/admin/internal/modules/resource/routes.go index 619194a7..ae42be63 100644 --- a/server/admin/internal/modules/resource/routes.go +++ b/server/admin/internal/modules/resource/routes.go @@ -25,6 +25,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { 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.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.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)