diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index ad2e8cc..234b4e7 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -101,6 +101,7 @@ func registerDashboardRoleRoutes(group *gin.RouterGroup) { func registerDashboardPermissionRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.PermissionGetBy) group.Any("/list", dashboard.PermissionAnyList) + group.POST("/sync", dashboard.PermissionPostSync) } func registerDashboardSessionRoutes(group *gin.RouterGroup) { diff --git a/internal/handlers/dashboard/permission_handler.go b/internal/handlers/dashboard/permission_handler.go index a769bb9..4af49af 100644 --- a/internal/handlers/dashboard/permission_handler.go +++ b/internal/handlers/dashboard/permission_handler.go @@ -74,3 +74,16 @@ func PermissionGetBy(ctx *gin.Context) { SortNo: item.SortNo, }) } + +func PermissionPostSync(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionPermissionSync); err != nil { + httpx.WriteJSON(ctx, err) + return + } + result, err := services.PermissionService.SyncBuiltinPermissions() + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} diff --git a/internal/pkg/dto/response/admin_response.go b/internal/pkg/dto/response/admin_response.go index 11ce434..4441451 100644 --- a/internal/pkg/dto/response/admin_response.go +++ b/internal/pkg/dto/response/admin_response.go @@ -14,6 +14,12 @@ type PermissionResponse struct { SortNo int `json:"sortNo"` } +type PermissionSyncResponse struct { + Created int `json:"created"` + Updated int `json:"updated"` + RolePermissionsAdded int `json:"rolePermissionsAdded"` +} + type RoleResponse struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/services/permission_service.go b/internal/services/permission_service.go index b2b4268..330c930 100644 --- a/internal/services/permission_service.go +++ b/internal/services/permission_service.go @@ -2,7 +2,12 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" + "fmt" + "time" "agent-desk/internal/pkg/httpx/params" @@ -65,3 +70,75 @@ func (s *permissionService) UpdateColumn(id int64, name string, value interface{ func (s *permissionService) Delete(id int64) { repositories.PermissionRepository.Delete(sqls.DB(), id) } + +func (s *permissionService) SyncBuiltinPermissions() (*response.PermissionSyncResponse, error) { + result := &response.PermissionSyncResponse{} + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions := make(map[string]*models.Permission, len(constants.Permissions)) + now := time.Now() + + for _, spec := range constants.Permissions { + permission := repositories.PermissionRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", spec.Code)) + if permission == nil { + permission = &models.Permission{ + Name: spec.Name, Code: spec.Code, Type: spec.Type, GroupName: spec.GroupName, + Method: spec.Method, APIPath: spec.APIPath, SortNo: spec.SortNo, + Status: enums.StatusOk, IsBuiltin: true, + AuditFields: systemPermissionAuditFields(now), + } + if err := repositories.PermissionRepository.Create(ctx.Tx, permission); err != nil { + return err + } + result.Created++ + } else { + if err := repositories.PermissionRepository.Updates(ctx.Tx, permission.ID, map[string]any{ + "name": spec.Name, "type": spec.Type, "group_name": spec.GroupName, + "method": spec.Method, "api_path": spec.APIPath, "sort_no": spec.SortNo, + "status": enums.StatusOk, "is_builtin": true, + "update_user_id": constants.SystemAuditUserID, + "update_user_name": constants.SystemAuditUserName, "updated_at": now, + }); err != nil { + return err + } + permission = repositories.PermissionRepository.Get(ctx.Tx, permission.ID) + result.Updated++ + } + permissions[spec.Code] = permission + } + + for roleCode, specs := range constants.RolePermissions { + role := repositories.RoleRepository.GetByCode(ctx.Tx, roleCode) + if role == nil { + return fmt.Errorf("builtin role not found: %s", roleCode) + } + for _, spec := range specs { + permission := permissions[spec.Code] + if permission == nil { + return fmt.Errorf("builtin permission not found: %s", spec.Code) + } + if repositories.RolePermissionRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("role_id", role.ID).Eq("permission_id", permission.ID)) != nil { + continue + } + if err := repositories.RolePermissionRepository.Create(ctx.Tx, &models.RolePermission{ + RoleID: role.ID, PermissionID: permission.ID, + AuditFields: systemPermissionAuditFields(now), + }); err != nil { + return err + } + result.RolePermissionsAdded++ + } + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func systemPermissionAuditFields(now time.Time) models.AuditFields { + return models.AuditFields{ + CreatedAt: now, CreateUserID: constants.SystemAuditUserID, CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, UpdateUserID: constants.SystemAuditUserID, UpdateUserName: constants.SystemAuditUserName, + } +} diff --git a/internal/services/permission_service_test.go b/internal/services/permission_service_test.go new file mode 100644 index 0000000..7752332 --- /dev/null +++ b/internal/services/permission_service_test.go @@ -0,0 +1,100 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestPermissionServiceSyncBuiltinPermissions(t *testing.T) { + db := setupPermissionServiceTestDB(t) + now := time.Now() + for _, spec := range constants.Roles { + if err := db.Create(&models.Role{ + Name: spec.Name, Code: spec.Code, Status: enums.StatusOk, IsSystem: true, SortNo: spec.SortNo, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }).Error; err != nil { + t.Fatalf("create role %s: %v", spec.Code, err) + } + } + customPermission := &models.Permission{ + Name: "Custom permission", Code: "custom.keep", Type: "api", GroupName: "custom", + Status: enums.StatusOk, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(customPermission).Error; err != nil { + t.Fatalf("create custom permission: %v", err) + } + superAdmin := &models.Role{} + if err := db.First(superAdmin, "code = ?", constants.RoleCodeSuperAdmin).Error; err != nil { + t.Fatalf("find super admin role: %v", err) + } + if err := db.Create(&models.RolePermission{ + RoleID: superAdmin.ID, PermissionID: customPermission.ID, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }).Error; err != nil { + t.Fatalf("create custom role permission: %v", err) + } + + first, err := PermissionService.SyncBuiltinPermissions() + if err != nil { + t.Fatalf("first sync: %v", err) + } + if first.Created != len(constants.Permissions) || first.Updated != 0 { + t.Fatalf("unexpected first sync result: %+v", first) + } + + wantRolePermissions := 0 + for _, permissions := range constants.RolePermissions { + wantRolePermissions += len(permissions) + } + if first.RolePermissionsAdded != wantRolePermissions { + t.Fatalf("role permissions added=%d want=%d", first.RolePermissionsAdded, wantRolePermissions) + } + + second, err := PermissionService.SyncBuiltinPermissions() + if err != nil { + t.Fatalf("second sync: %v", err) + } + if second.Created != 0 || second.Updated != len(constants.Permissions) || second.RolePermissionsAdded != 0 { + t.Fatalf("sync is not idempotent: %+v", second) + } + + var permissionCount int64 + if err := db.Model(&models.Permission{}).Count(&permissionCount).Error; err != nil { + t.Fatalf("count permissions: %v", err) + } + if permissionCount != int64(len(constants.Permissions)+1) { + t.Fatalf("permission count=%d want=%d", permissionCount, len(constants.Permissions)+1) + } + var customRolePermissionCount int64 + if err := db.Model(&models.RolePermission{}). + Where("role_id = ? AND permission_id = ?", superAdmin.ID, customPermission.ID). + Count(&customRolePermissionCount).Error; err != nil { + t.Fatalf("count custom role permission: %v", err) + } + if customRolePermissionCount != 1 { + t.Fatalf("custom role permission was removed") + } +} + +func setupPermissionServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.Role{}, &models.Permission{}, &models.RolePermission{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/web/app/dashboard/permissions/page.tsx b/web/app/dashboard/permissions/page.tsx index 4aebd5b..0479ab1 100644 --- a/web/app/dashboard/permissions/page.tsx +++ b/web/app/dashboard/permissions/page.tsx @@ -1,10 +1,14 @@ "use client" -import { KeyRoundIcon, RouteIcon, SearchIcon } from "lucide-react" +import { useState } from "react" +import { KeyRoundIcon, RouteIcon, SearchIcon, ShieldCheckIcon } from "lucide-react" +import { toast } from "sonner" +import { useConfirm } from "@/components/confirm-provider" import { DashboardListPage } from "@/components/dashboard/list" import { Badge } from "@/components/ui/badge" -import { fetchPermissions, type AdminPermission } from "@/lib/api/admin" +import { Button } from "@/components/ui/button" +import { fetchPermissions, syncPermissions, type AdminPermission } from "@/lib/api/admin" import { Status } from "@/lib/generated/enums" import { useAppLocale, useI18n } from "@/i18n/provider" import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n" @@ -12,6 +16,8 @@ import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permissi export default function DashboardPermissionsPage() { const t = useI18n() const { locale } = useAppLocale() + const confirm = useConfirm() + const [syncing, setSyncing] = useState(false) const listStatusOptions = [ { value: "all", label: t("status.all") }, { value: String(Status.Ok), label: t("status.ok") }, @@ -19,6 +25,30 @@ export default function DashboardPermissionsPage() { { value: String(Status.Deleted), label: t("status.deleted") }, ] + async function handleSync(reload: () => Promise) { + const confirmed = await confirm({ + title: t("permission.syncTitle"), + description: t("permission.syncDescription"), + confirmText: t("permission.sync"), + }) + if (!confirmed || syncing) return + + setSyncing(true) + try { + const result = await syncPermissions() + toast.success(t("permission.syncSuccess", { + created: result.created, + updated: result.updated, + rolePermissionsAdded: result.rolePermissionsAdded, + })) + await reload() + } catch (error) { + toast.error(error instanceof Error ? error.message : t("permission.syncFailed")) + } finally { + setSyncing(false) + } + } + return ( filters={[ @@ -51,6 +81,12 @@ export default function DashboardPermissionsPage() { }, ]} fetchList={fetchPermissions} + renderToolbarActions={({ reload }) => ( + + )} getItemId={(item) => item.id} columns={[ { diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 02c400b..0a52162 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -91,6 +91,12 @@ export type AdminPermission = { sortNo: number } +export type PermissionSyncResult = { + created: number + updated: number + rolePermissionsAdded: number +} + export type ConversationTag = { id: number name: string @@ -1240,6 +1246,12 @@ export function fetchPermissions( ) } +export function syncPermissions() { + return request("/api/dashboard/permission/sync", { + method: "POST", + }) +} + export function fetchConversations( query?: Record ) { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 5cb4ad6..d0a8659 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2196,6 +2196,12 @@ "permission": { "loadFailed": "Could not load permissions.", "refresh": "Refresh", + "sync": "Sync Permissions", + "syncing": "Syncing...", + "syncTitle": "Sync Built-in Permissions", + "syncDescription": "Sync built-in permissions from code and add missing permissions to built-in roles. Existing role permissions will not be removed.", + "syncSuccess": "Permissions synced: {created} created, {updated} updated, {rolePermissionsAdded} role permissions added", + "syncFailed": "Could not sync permissions.", "filterKeyword": "Filter by permission name or code", "filterGroup": "Filter by group", "query": "Search", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 1cf7a75..78f6016 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2196,6 +2196,12 @@ "permission": { "loadFailed": "加载权限失败", "refresh": "刷新", + "sync": "同步权限", + "syncing": "同步中...", + "syncTitle": "同步内置权限", + "syncDescription": "将代码中的内置权限同步到数据库,并补齐内置角色缺失的权限。已有角色权限不会被删除。", + "syncSuccess": "权限同步完成:新增 {created},更新 {updated},补齐角色权限 {rolePermissionsAdded}", + "syncFailed": "权限同步失败", "filterKeyword": "按权限名称/编码筛选", "filterGroup": "按分组筛选", "query": "查询",