身份自动发放

This commit is contained in:
zhx 2026-06-25 16:14:07 +08:00
parent 9b4447e158
commit 7cd08d0967
3 changed files with 344 additions and 2 deletions

View File

@ -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
}

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

View File

@ -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)