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, ",") }