diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 93f2ee3..52bb9fb 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -32,6 +32,12 @@ export default { taskDetail: 'Task detail', sourceMessageConversation: 'Email conversation', debugEml: 'Debug EML', + systemSettings: 'System settings', + systemUsers: 'Users', + systemRoles: 'Roles', + systemMenus: 'Menus', + systemHotels: 'Hotels', + systemAudits: 'Admin audits', }, common: { loading: 'Loading', @@ -51,6 +57,134 @@ export default { pageStatus: 'Page {current} / {total}', pageSize: 'Page size', }, + notFound: { + title: 'Page unavailable', + message: 'The frontend page for this menu or link is not available yet.', + backHome: 'Back to available page', + }, + systemAdmin: { + title: 'System settings', + subtitle: 'Maintain users, roles, permissions, menus, and hotel records', + navigationAria: 'System settings navigation', + common: { + keyword: 'Keyword', + viewDetail: 'View detail', + selectRow: 'Select a row to view detail', + create: 'Create', + save: 'Save', + edit: 'Edit', + yes: 'Yes', + no: 'No', + }, + status: { + ACTIVE: 'Active', + DISABLED: 'Disabled', + }, + users: { + title: 'User filters', + tableTitle: 'Users', + detailTitle: 'User detail', + createTitle: 'Create user', + totalSuffix: 'users', + keywordPlaceholder: 'Username / display name / email', + userStatus: 'User status', + username: 'Username', + initialPassword: 'Initial password', + displayName: 'Display name', + email: 'Email', + phone: 'Phone', + roles: 'Roles', + hotels: 'Authorized hotels', + defaultHotel: 'Default hotel', + saveRoles: 'Save roles', + saveHotels: 'Save hotels', + resetPassword: 'Reset password', + temporaryPassword: 'Temporary password:', + created: 'User created', + columns: { + user: 'User', + status: 'Status', + roles: 'Roles', + defaultHotel: 'Default hotel', + }, + }, + roles: { + title: 'Role filters', + tableTitle: 'Roles', + detailTitle: 'Role detail', + createTitle: 'Create custom role', + totalSuffix: 'roles', + keywordPlaceholder: 'Role code / role name', + roleStatus: 'Role status', + roleCode: 'Role code', + roleName: 'Role name', + builtinReadonly: 'Built-in roles are maintained by the system bootstrap matrix and cannot be edited here.', + savePermissions: 'Save permissions', + columns: { + role: 'Role', + status: 'Status', + builtin: 'Built-in', + permissions: 'Permissions', + users: 'Users', + }, + }, + menus: { + title: 'Menu filters', + createTitle: 'Create menu', + editTitle: 'Edit menu', + tableTitle: 'Menus', + totalSuffix: 'menus', + keywordPlaceholder: 'Menu code / name / route', + menuStatus: 'Menu status', + menuCode: 'Menu code', + menuName: 'Menu name', + componentKey: 'Component key', + iconKey: 'Icon key', + columns: { + menu: 'Menu', + route: 'Route', + permission: 'Permission', + visible: 'Visible', + status: 'Status', + sort: 'Sort', + knownRoute: 'Known route', + }, + }, + hotels: { + title: 'Hotel filters', + createTitle: 'Create hotel', + editTitle: 'Edit hotel', + tableTitle: 'Hotels', + totalSuffix: 'hotels', + keywordPlaceholder: 'Hotel ID / hotel name', + hotelStatus: 'Hotel status', + hotelId: 'Hotel ID', + hotelName: 'Hotel name', + saveStatus: 'Save status', + singleActiveRule: 'Single-hotel mode allows only one ACTIVE hotel. Newly created hotels are disabled by default.', + columns: { + hotel: 'Hotel', + status: 'Status', + timeZone: 'Time zone', + authorizedUsers: 'Authorized users', + sort: 'Sort', + }, + }, + audits: { + title: 'Audit filters', + tableTitle: 'Admin audit data', + totalSuffix: 'audit records', + targetType: 'Target type', + targetId: 'Target ID', + action: 'Action', + columns: { + action: 'Action', + target: 'Target', + actor: 'Actor', + occurredAt: 'Occurred at', + }, + }, + }, workbench: { title: 'Order workbench', subtitle: 'Review, filter, and handle Reservation order tasks', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 47a2012..23629a7 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -32,6 +32,12 @@ export default { taskDetail: 'รายละเอียดงาน', sourceMessageConversation: 'เธรดอีเมล', debugEml: 'Debug EML', + systemSettings: 'ตั้งค่าระบบ', + systemUsers: 'ผู้ใช้', + systemRoles: 'บทบาท', + systemMenus: 'เมนู', + systemHotels: 'โรงแรม', + systemAudits: 'บันทึกผู้ดูแล', }, common: { loading: 'กำลังโหลด', @@ -51,6 +57,134 @@ export default { pageStatus: 'หน้า {current} / {total}', pageSize: 'ต่อหน้า', }, + notFound: { + title: 'หน้านี้ยังไม่พร้อม', + message: 'เมนูหรือลิงก์นี้ยังไม่มีหน้าฝั่งหน้าเว็บรองรับ', + backHome: 'กลับไปหน้าที่เข้าถึงได้', + }, + systemAdmin: { + title: 'ตั้งค่าระบบ', + subtitle: 'ดูแลผู้ใช้ บทบาท สิทธิ์ เมนู และข้อมูลโรงแรม', + navigationAria: 'เมนูตั้งค่าระบบ', + common: { + keyword: 'คำค้น', + viewDetail: 'ดูรายละเอียด', + selectRow: 'เลือกหนึ่งแถวเพื่อดูรายละเอียด', + create: 'สร้าง', + save: 'บันทึก', + edit: 'แก้ไข', + yes: 'ใช่', + no: 'ไม่ใช่', + }, + status: { + ACTIVE: 'เปิดใช้งาน', + DISABLED: 'ปิดใช้งาน', + }, + users: { + title: 'ตัวกรองผู้ใช้', + tableTitle: 'ข้อมูลผู้ใช้', + detailTitle: 'รายละเอียดผู้ใช้', + createTitle: 'สร้างผู้ใช้', + totalSuffix: 'ผู้ใช้', + keywordPlaceholder: 'ชื่อผู้ใช้ / ชื่อแสดง / อีเมล', + userStatus: 'สถานะผู้ใช้', + username: 'ชื่อผู้ใช้', + initialPassword: 'รหัสผ่านเริ่มต้น', + displayName: 'ชื่อแสดง', + email: 'อีเมล', + phone: 'โทรศัพท์', + roles: 'บทบาท', + hotels: 'โรงแรมที่มีสิทธิ์', + defaultHotel: 'โรงแรมเริ่มต้น', + saveRoles: 'บันทึกบทบาท', + saveHotels: 'บันทึกสิทธิ์โรงแรม', + resetPassword: 'รีเซ็ตรหัสผ่าน', + temporaryPassword: 'รหัสผ่านชั่วคราว:', + created: 'สร้างผู้ใช้แล้ว', + columns: { + user: 'ผู้ใช้', + status: 'สถานะ', + roles: 'บทบาท', + defaultHotel: 'โรงแรมเริ่มต้น', + }, + }, + roles: { + title: 'ตัวกรองบทบาท', + tableTitle: 'ข้อมูลบทบาท', + detailTitle: 'รายละเอียดบทบาท', + createTitle: 'สร้างบทบาทเอง', + totalSuffix: 'บทบาท', + keywordPlaceholder: 'รหัสบทบาท / ชื่อบทบาท', + roleStatus: 'สถานะบทบาท', + roleCode: 'รหัสบทบาท', + roleName: 'ชื่อบทบาท', + builtinReadonly: 'บทบาทระบบถูกดูแลโดยระบบเริ่มต้น จึงแก้ไขที่นี่ไม่ได้', + savePermissions: 'บันทึกสิทธิ์', + columns: { + role: 'บทบาท', + status: 'สถานะ', + builtin: 'ระบบ', + permissions: 'สิทธิ์', + users: 'จำนวนผู้ใช้', + }, + }, + menus: { + title: 'ตัวกรองเมนู', + createTitle: 'สร้างเมนู', + editTitle: 'แก้ไขเมนู', + tableTitle: 'ข้อมูลเมนู', + totalSuffix: 'เมนู', + keywordPlaceholder: 'รหัสเมนู / ชื่อ / เส้นทาง', + menuStatus: 'สถานะเมนู', + menuCode: 'รหัสเมนู', + menuName: 'ชื่อเมนู', + componentKey: 'รหัสคอมโพเนนต์', + iconKey: 'รหัสไอคอน', + columns: { + menu: 'เมนู', + route: 'เส้นทาง', + permission: 'สิทธิ์', + visible: 'แสดง', + status: 'สถานะ', + sort: 'ลำดับ', + knownRoute: 'เส้นทางที่รู้จัก', + }, + }, + hotels: { + title: 'ตัวกรองโรงแรม', + createTitle: 'สร้างโรงแรม', + editTitle: 'แก้ไขโรงแรม', + tableTitle: 'ข้อมูลโรงแรม', + totalSuffix: 'โรงแรม', + keywordPlaceholder: 'รหัสโรงแรม / ชื่อโรงแรม', + hotelStatus: 'สถานะโรงแรม', + hotelId: 'รหัสโรงแรม', + hotelName: 'ชื่อโรงแรม', + saveStatus: 'บันทึกสถานะ', + singleActiveRule: 'โหมดโรงแรมเดียวอนุญาตให้มีโรงแรม ACTIVE ได้เพียงหนึ่งแห่ง โรงแรมที่สร้างใหม่จะปิดใช้งานก่อน', + columns: { + hotel: 'โรงแรม', + status: 'สถานะ', + timeZone: 'เขตเวลา', + authorizedUsers: 'ผู้ใช้ที่มีสิทธิ์', + sort: 'ลำดับ', + }, + }, + audits: { + title: 'ตัวกรองบันทึก', + tableTitle: 'ข้อมูลบันทึกผู้ดูแล', + totalSuffix: 'รายการบันทึก', + targetType: 'ประเภทเป้าหมาย', + targetId: 'รหัสเป้าหมาย', + action: 'การดำเนินการ', + columns: { + action: 'การดำเนินการ', + target: 'เป้าหมาย', + actor: 'ผู้ดำเนินการ', + occurredAt: 'เวลาเกิดเหตุ', + }, + }, + }, workbench: { title: 'โต๊ะงานออเดอร์', subtitle: 'ดู กรอง และจัดการงานออเดอร์การจอง', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index caea094..c7507cf 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -32,6 +32,12 @@ export default { taskDetail: '任务详情', sourceMessageConversation: '邮件会话', debugEml: 'Debug EML', + systemSettings: '系统设置', + systemUsers: '用户管理', + systemRoles: '角色权限', + systemMenus: '菜单管理', + systemHotels: '酒店管理', + systemAudits: '管理审计', }, common: { loading: '加载中', @@ -51,6 +57,134 @@ export default { pageStatus: '第 {current} / {total} 页', pageSize: '每页', }, + notFound: { + title: '页面不可用', + message: '当前菜单或链接对应的前端页面尚未接入。', + backHome: '返回可访问页面', + }, + systemAdmin: { + title: '系统设置', + subtitle: '维护用户、角色权限、菜单和酒店资料', + navigationAria: '系统设置导航', + common: { + keyword: '关键词', + viewDetail: '查看详情', + selectRow: '选择一行查看详情', + create: '新增', + save: '保存', + edit: '编辑', + yes: '是', + no: '否', + }, + status: { + ACTIVE: '启用', + DISABLED: '禁用', + }, + users: { + title: '用户筛选', + tableTitle: '用户数据', + detailTitle: '用户详情', + createTitle: '新增用户', + totalSuffix: '个用户', + keywordPlaceholder: '用户名 / 展示名 / 邮箱', + userStatus: '用户状态', + username: '用户名', + initialPassword: '初始密码', + displayName: '展示名称', + email: '邮箱', + phone: '手机号', + roles: '角色', + hotels: '授权酒店', + defaultHotel: '默认酒店', + saveRoles: '保存角色', + saveHotels: '保存酒店授权', + resetPassword: '重置密码', + temporaryPassword: '临时密码:', + created: '用户已创建', + columns: { + user: '用户', + status: '状态', + roles: '角色', + defaultHotel: '默认酒店', + }, + }, + roles: { + title: '角色筛选', + tableTitle: '角色数据', + detailTitle: '角色详情', + createTitle: '新增自定义角色', + totalSuffix: '个角色', + keywordPlaceholder: '角色代码 / 角色名称', + roleStatus: '角色状态', + roleCode: '角色代码', + roleName: '角色名称', + builtinReadonly: '内置角色由系统启动矩阵维护,不能在页面修改。', + savePermissions: '保存权限', + columns: { + role: '角色', + status: '状态', + builtin: '内置', + permissions: '权限', + users: '用户数', + }, + }, + menus: { + title: '菜单筛选', + createTitle: '新增菜单', + editTitle: '编辑菜单', + tableTitle: '菜单数据', + totalSuffix: '个菜单', + keywordPlaceholder: '菜单代码 / 名称 / 路由', + menuStatus: '菜单状态', + menuCode: '菜单代码', + menuName: '菜单名称', + componentKey: '组件标识', + iconKey: '图标标识', + columns: { + menu: '菜单', + route: '路由', + permission: '权限码', + visible: '可见', + status: '状态', + sort: '排序', + knownRoute: '已知路由', + }, + }, + hotels: { + title: '酒店筛选', + createTitle: '新增酒店', + editTitle: '编辑酒店', + tableTitle: '酒店数据', + totalSuffix: '家酒店', + keywordPlaceholder: '酒店 ID / 酒店名称', + hotelStatus: '酒店状态', + hotelId: '酒店 ID', + hotelName: '酒店名称', + saveStatus: '保存状态', + singleActiveRule: '单酒店阶段只允许一家 ACTIVE 酒店;新增酒店默认禁用。', + columns: { + hotel: '酒店', + status: '状态', + timeZone: '时区', + authorizedUsers: '授权用户', + sort: '排序', + }, + }, + audits: { + title: '审计筛选', + tableTitle: '管理审计数据', + totalSuffix: '条审计记录', + targetType: '对象类型', + targetId: '对象 ID', + action: '操作类型', + columns: { + action: '操作', + target: '对象', + actor: '操作者', + occurredAt: '发生时间', + }, + }, + }, workbench: { title: '订单工作台', subtitle: '查看、筛选和处理预订订单任务', diff --git a/client/src/layouts/ReservationAppShell.vue b/client/src/layouts/ReservationAppShell.vue index 8d064d2..cdaa643 100644 --- a/client/src/layouts/ReservationAppShell.vue +++ b/client/src/layouts/ReservationAppShell.vue @@ -174,6 +174,7 @@ const menuLabelKeys: Record = { RESERVATION_ORDERS: 'nav.orders', RESERVATION_TASKS: 'nav.taskQueue', DEBUG_EML_SUPERAGENT: 'nav.debugEml', + SYSTEM_SETTINGS: 'nav.systemSettings', } const navItems = computed(() => authStore.visibleMenus) diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 6662e97..c78cb55 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -98,6 +98,69 @@ export const router = createRouter({ permission: 'SYSTEM_DEBUG_EML_RUN', }, }, + { + path: '/system', + component: () => import('@/views/system/SystemAdminLayoutView.vue'), + meta: { + titleKey: 'nav.systemSettings', + permission: 'SYSTEM_ADMIN_CONSOLE_ACCESS', + }, + children: [ + { + path: 'users', + name: 'system-users', + component: () => import('@/views/system/SystemUsersView.vue'), + meta: { + titleKey: 'nav.systemUsers', + permission: 'SYSTEM_USER_MANAGE', + }, + }, + { + path: 'roles', + name: 'system-roles', + component: () => import('@/views/system/SystemRolesView.vue'), + meta: { + titleKey: 'nav.systemRoles', + permission: 'SYSTEM_ROLE_MANAGE', + }, + }, + { + path: 'menus', + name: 'system-menus', + component: () => import('@/views/system/SystemMenusView.vue'), + meta: { + titleKey: 'nav.systemMenus', + permission: 'SYSTEM_MENU_MANAGE', + }, + }, + { + path: 'hotels', + name: 'system-hotels', + component: () => import('@/views/system/SystemHotelsView.vue'), + meta: { + titleKey: 'nav.systemHotels', + permission: 'HOTEL_MANAGE', + }, + }, + { + path: 'audits', + name: 'system-audits', + component: () => import('@/views/system/SystemAuditsView.vue'), + meta: { + titleKey: 'nav.systemAudits', + permission: 'SYSTEM_ADMIN_CONSOLE_ACCESS', + }, + }, + ], + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/views/system/RouteNotFoundView.vue'), + meta: { + titleKey: 'notFound.title', + }, + }, ], }) @@ -138,6 +201,26 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut } } + if (to.path === '/system') { + return resolveSystemAdminEntryPath(authStore) + } + return true } } + +function resolveSystemAdminEntryPath(authStore: AuthStore): string { + if (authStore.hasPermission('SYSTEM_USER_MANAGE')) { + return '/system/users' + } + if (authStore.hasPermission('SYSTEM_ROLE_MANAGE')) { + return '/system/roles' + } + if (authStore.hasPermission('SYSTEM_MENU_MANAGE')) { + return '/system/menus' + } + if (authStore.hasPermission('HOTEL_MANAGE')) { + return '/system/hotels' + } + return '/system/audits' +} diff --git a/client/src/services/systemAdminService.ts b/client/src/services/systemAdminService.ts new file mode 100644 index 0000000..efbf5e5 --- /dev/null +++ b/client/src/services/systemAdminService.ts @@ -0,0 +1,171 @@ +import { getJson, sendJson } from '@/services/httpClient' +import type { + AdminAuditLogListFilters, + AdminAuditLogResult, + AdminHotelCreateRequest, + AdminHotelListFilters, + AdminHotelResult, + AdminHotelStatusUpdateRequest, + AdminHotelUpdateRequest, + AdminMenuCreateRequest, + AdminMenuListFilters, + AdminMenuResult, + AdminMenuUpdateRequest, + AdminPermissionResult, + AdminRoleCreateRequest, + AdminRoleDetailResult, + AdminRoleListFilters, + AdminRoleListItemResult, + AdminRolePermissionAssignmentRequest, + AdminRoleUpdateRequest, + AdminUserCreateRequest, + AdminUserDetailResult, + AdminUserHotelAssignmentRequest, + AdminUserListFilters, + AdminUserListItemResult, + AdminUserPasswordResetRequest, + AdminUserPasswordResetResult, + AdminUserRoleAssignmentRequest, + AdminUserUpdateRequest, + PlatformPageResult, +} from '@/types/systemAdmin' + +export async function fetchAdminUsers( + filters: AdminUserListFilters = {}, +): Promise> { + return getJson>(withQuery('/api/admin/users', filters)) +} + +export async function fetchAdminUserDetail(userId: string): Promise { + return getJson(`/api/admin/users/${encodeURIComponent(userId)}`) +} + +export async function createAdminUser(request: AdminUserCreateRequest): Promise { + return sendJson('/api/admin/users', 'POST', request) +} + +export async function updateAdminUser( + userId: string, + request: AdminUserUpdateRequest, +): Promise { + return sendJson(`/api/admin/users/${encodeURIComponent(userId)}`, 'PUT', request) +} + +export async function assignAdminUserRoles( + userId: string, + request: AdminUserRoleAssignmentRequest, +): Promise { + return sendJson(`/api/admin/users/${encodeURIComponent(userId)}/roles`, 'PUT', request) +} + +export async function assignAdminUserHotels( + userId: string, + request: AdminUserHotelAssignmentRequest, +): Promise { + return sendJson(`/api/admin/users/${encodeURIComponent(userId)}/hotels`, 'PUT', request) +} + +export async function resetAdminUserPassword( + userId: string, + request: AdminUserPasswordResetRequest = {}, +): Promise { + return sendJson( + `/api/admin/users/${encodeURIComponent(userId)}/password-reset`, + 'POST', + request, + ) +} + +export async function fetchAdminRoles( + filters: AdminRoleListFilters = {}, +): Promise> { + return getJson>(withQuery('/api/admin/roles', filters)) +} + +export async function fetchAdminRoleDetail(roleId: string): Promise { + return getJson(`/api/admin/roles/${encodeURIComponent(roleId)}`) +} + +export async function createAdminRole(request: AdminRoleCreateRequest): Promise { + return sendJson('/api/admin/roles', 'POST', request) +} + +export async function updateAdminRole( + roleId: string, + request: AdminRoleUpdateRequest, +): Promise { + return sendJson(`/api/admin/roles/${encodeURIComponent(roleId)}`, 'PUT', request) +} + +export async function assignAdminRolePermissions( + roleId: string, + request: AdminRolePermissionAssignmentRequest, +): Promise { + return sendJson( + `/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, + 'PUT', + request, + ) +} + +export async function fetchAdminPermissions(): Promise { + return getJson('/api/admin/permissions') +} + +export async function fetchAdminMenus( + filters: AdminMenuListFilters = {}, +): Promise> { + return getJson>(withQuery('/api/admin/menus', filters)) +} + +export async function createAdminMenu(request: AdminMenuCreateRequest): Promise { + return sendJson('/api/admin/menus', 'POST', request) +} + +export async function updateAdminMenu(menuId: string, request: AdminMenuUpdateRequest): Promise { + return sendJson(`/api/admin/menus/${encodeURIComponent(menuId)}`, 'PUT', request) +} + +export async function fetchAdminHotels( + filters: AdminHotelListFilters = {}, +): Promise> { + return getJson>(withQuery('/api/admin/hotels', filters)) +} + +export async function createAdminHotel(request: AdminHotelCreateRequest): Promise { + return sendJson('/api/admin/hotels', 'POST', request) +} + +export async function updateAdminHotel(hotelId: string, request: AdminHotelUpdateRequest): Promise { + return sendJson(`/api/admin/hotels/${encodeURIComponent(hotelId)}`, 'PUT', request) +} + +export async function updateAdminHotelStatus( + hotelId: string, + request: AdminHotelStatusUpdateRequest, +): Promise { + return sendJson(`/api/admin/hotels/${encodeURIComponent(hotelId)}/status`, 'PUT', request) +} + +export async function fetchAdminAudits( + filters: AdminAuditLogListFilters = {}, +): Promise> { + return getJson>(withQuery('/api/admin/audits', filters)) +} + +function withQuery(path: string, params: object): string { + const searchParams = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if ( + value === undefined || + value === null || + value === '' || + !['string', 'number', 'boolean'].includes(typeof value) + ) { + return + } + searchParams.set(key, String(value)) + }) + const query = searchParams.toString() + return query ? `${path}?${query}` : path +} diff --git a/client/src/tests/reservationAppShell.spec.ts b/client/src/tests/reservationAppShell.spec.ts index 46bf833..8826441 100644 --- a/client/src/tests/reservationAppShell.spec.ts +++ b/client/src/tests/reservationAppShell.spec.ts @@ -79,6 +79,18 @@ function createDebugMenu(): AuthMenuResult { } } +function createSystemMenu(): AuthMenuResult { + return { + menu_code: 'SYSTEM_SETTINGS', + menu_name: '系统设置', + route_path: '/system', + component_key: 'SystemSettings', + icon_key: 'pi pi-cog', + permission_code: 'SYSTEM_ADMIN_CONSOLE_ACCESS', + sort_order: 40, + } +} + async function mountShell(menus = [createReservationOrdersMenu(), createReservationTasksMenu()]) { const i18n = createI18n({ legacy: false, @@ -108,6 +120,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat component: { template: '
' }, meta: { titleKey: 'debugEml.title' }, }, + { + path: '/system', + component: { template: '
' }, + meta: { titleKey: 'nav.systemSettings' }, + }, ], }) const pinia = createPinia() @@ -158,6 +175,13 @@ describe('ReservationAppShell', () => { expect(wrapper.text()).toContain('Debug EML') }) + it('shows System Settings only when backend menus include it', async () => { + const wrapper = await mountShell([createReservationOrdersMenu(), createSystemMenu()]) + + expect(wrapper.find('a[href="/system"]').exists()).toBe(true) + expect(wrapper.text()).toContain('系统设置') + }) + it('shows current user and hotel context in the top bar', async () => { const wrapper = await mountShell() diff --git a/client/src/tests/reservationRouter.spec.ts b/client/src/tests/reservationRouter.spec.ts index dc41fd9..d8d5201 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -53,6 +53,15 @@ describe('reservation router', () => { expect(router.resolve('/debug/eml-superagent').name).toBe('debug-eml-superagent') }) + it('exposes the System Admin V1 routes', () => { + expect(router.resolve('/system/users').name).toBe('system-users') + expect(router.resolve('/system/roles').name).toBe('system-roles') + expect(router.resolve('/system/menus').name).toBe('system-menus') + expect(router.resolve('/system/hotels').name).toBe('system-hotels') + expect(router.resolve('/system/audits').name).toBe('system-audits') + expect(router.resolve('/system/not-ready').name).toBe('not-found') + }) + it('declares permissions for protected P0 routes', () => { expect(router.resolve('/reservation/orders').meta.permission).toBe('RESERVATION_ORDER_READ') expect(router.resolve('/reservation/orders/20001').meta.permission).toBe('RESERVATION_ORDER_READ') @@ -62,6 +71,11 @@ describe('reservation router', () => { 'SOURCE_MESSAGE_ORIGINAL_READ', ) expect(router.resolve('/debug/eml-superagent').meta.permission).toBe('SYSTEM_DEBUG_EML_RUN') + expect(router.resolve('/system/users').meta.permission).toBe('SYSTEM_USER_MANAGE') + expect(router.resolve('/system/roles').meta.permission).toBe('SYSTEM_ROLE_MANAGE') + expect(router.resolve('/system/menus').meta.permission).toBe('SYSTEM_MENU_MANAGE') + expect(router.resolve('/system/hotels').meta.permission).toBe('HOTEL_MANAGE') + expect(router.resolve('/system/audits').meta.permission).toBe('SYSTEM_ADMIN_CONSOLE_ACCESS') }) it('redirects anonymous users to login with the protected target as redirect', async () => { @@ -119,6 +133,19 @@ describe('reservation router', () => { expect(restored).toBe(true) }) + it('redirects the System Admin entry to the first allowed management page', async () => { + const guard = createAuthGuard(() => + createAuthStoreForGuard({ + hasPermission: (permission: string) => + permission === 'SYSTEM_ADMIN_CONSOLE_ACCESS' || permission === 'HOTEL_MANAGE', + }), + ) + + await expect(guard(createRoute('/system', { permission: 'SYSTEM_ADMIN_CONSOLE_ACCESS' }))).resolves.toBe( + '/system/hotels', + ) + }) + it('redirects the root route to the default page when the backend menu points to root', async () => { const guard = createAuthGuard(() => createAuthStoreForGuard({ diff --git a/client/src/tests/systemAdminService.spec.ts b/client/src/tests/systemAdminService.spec.ts new file mode 100644 index 0000000..0804f53 --- /dev/null +++ b/client/src/tests/systemAdminService.spec.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + assignAdminRolePermissions, + assignAdminUserHotels, + assignAdminUserRoles, + createAdminHotel, + createAdminMenu, + createAdminRole, + createAdminUser, + fetchAdminAudits, + fetchAdminHotels, + fetchAdminMenus, + fetchAdminPermissions, + fetchAdminRoleDetail, + fetchAdminRoles, + fetchAdminUserDetail, + fetchAdminUsers, + resetAdminUserPassword, + updateAdminHotel, + updateAdminHotelStatus, + updateAdminMenu, + updateAdminRole, + updateAdminUser, +} from '@/services/systemAdminService' + +const jsonHeaders = { + headers: { + get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null), + }, +} + +function mockJsonResponse(payload: unknown): Response { + return { + ok: true, + status: 200, + json: async () => payload, + text: async () => JSON.stringify(payload), + ...jsonHeaders, + } as Response +} + +describe('systemAdminService', () => { + beforeEach(() => { + vi.restoreAllMocks() + sessionStorage.clear() + }) + + it('fetches admin users with filters', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse({ + items: [], + page: { + page_num: 2, + page_size: 10, + total: 0, + }, + }), + ) + + await fetchAdminUsers({ + keyword: 'admin', + user_status: 'ACTIVE', + page_num: 2, + page_size: 10, + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/admin/users?keyword=admin&user_status=ACTIVE&page_num=2&page_size=10', + expect.objectContaining({ method: 'GET' }), + ) + }) + + it('fetches role, menu, hotel lists and details from admin endpoints', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ items: [], page: {} })) + + await fetchAdminRoles({ role_status: 'DISABLED' }) + await fetchAdminMenus({ menu_status: 'ACTIVE' }) + await fetchAdminHotels({ hotel_status: 'ACTIVE' }) + await fetchAdminUserDetail('10001') + await fetchAdminRoleDetail('20001') + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/admin/roles?role_status=DISABLED', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/admin/menus?menu_status=ACTIVE', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/admin/hotels?hotel_status=ACTIVE', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + '/api/admin/users/10001', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + '/api/admin/roles/20001', + expect.objectContaining({ method: 'GET' }), + ) + }) + + it('fetches the readonly permission list', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse([ + { + id: '1', + permission_code: 'SYSTEM_USER_MANAGE', + permission_name: '用户管理', + permission_group: 'SYSTEM', + permission_status: 'ACTIVE', + }, + ]), + ) + + const result = await fetchAdminPermissions() + + expect(fetchMock).toHaveBeenCalledWith('/api/admin/permissions', expect.objectContaining({ method: 'GET' })) + expect(result[0]?.permission_code).toBe('SYSTEM_USER_MANAGE') + }) + + it('sends admin user write operations to backend endpoints', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ id: '10001' })) + + await createAdminUser({ + username: 'operator', + initial_password: 'User@123456', + display_name: 'Operator', + user_status: 'ACTIVE', + role_ids: ['20001'], + hotel_ids: ['HOTEL-TEST'], + default_hotel_id: 'HOTEL-TEST', + }) + await updateAdminUser('10001', { + display_name: 'Operator A', + user_status: 'DISABLED', + }) + await assignAdminUserRoles('10001', { + role_ids: ['20002'], + }) + await assignAdminUserHotels('10001', { + hotel_ids: ['HOTEL-TEST'], + default_hotel_id: 'HOTEL-TEST', + }) + await resetAdminUserPassword('10001') + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/admin/users', + expect.objectContaining({ method: 'POST' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/admin/users/10001', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/admin/users/10001/roles', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + '/api/admin/users/10001/hotels', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + '/api/admin/users/10001/password-reset', + expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }), + ) + }) + + it('sends role, menu, hotel writes and audit queries to admin endpoints', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ id: '1', items: [], page: {} })) + + await createAdminRole({ role_code: 'CUSTOM_ROLE', role_name: 'Custom role', role_status: 'ACTIVE' }) + await updateAdminRole('20001', { role_name: 'Custom role A', role_status: 'DISABLED' }) + await assignAdminRolePermissions('20001', { permission_ids: ['30001'] }) + await createAdminMenu({ + menu_code: 'CUSTOM_MENU', + menu_name: 'Custom menu', + menu_type: 'PAGE', + visible: false, + menu_status: 'DISABLED', + }) + await updateAdminMenu('40001', { + menu_name: 'Custom menu A', + menu_type: 'PAGE', + visible: true, + menu_status: 'ACTIVE', + }) + await createAdminHotel({ + hotel_id: 'HOTEL-BKK', + hotel_name: 'Bangkok Hotel', + time_zone: 'Asia/Bangkok', + }) + await updateAdminHotel('HOTEL-BKK', { + hotel_name: 'Bangkok Hotel A', + time_zone: 'Asia/Bangkok', + }) + await updateAdminHotelStatus('HOTEL-BKK', { hotel_status: 'ACTIVE' }) + await fetchAdminAudits({ target_type: 'PLATFORM_USER', page_num: 1, page_size: 20 }) + + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/admin/roles', expect.objectContaining({ method: 'POST' })) + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/admin/roles/20001', expect.objectContaining({ method: 'PUT' })) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/admin/roles/20001/permissions', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/admin/menus', expect.objectContaining({ method: 'POST' })) + expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/admin/menus/40001', expect.objectContaining({ method: 'PUT' })) + expect(fetchMock).toHaveBeenNthCalledWith(6, '/api/admin/hotels', expect.objectContaining({ method: 'POST' })) + expect(fetchMock).toHaveBeenNthCalledWith( + 7, + '/api/admin/hotels/HOTEL-BKK', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 8, + '/api/admin/hotels/HOTEL-BKK/status', + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 9, + '/api/admin/audits?target_type=PLATFORM_USER&page_num=1&page_size=20', + expect.objectContaining({ method: 'GET' }), + ) + }) +}) diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts index a5fa35b..7cfbfcb 100644 --- a/client/src/types/auth.ts +++ b/client/src/types/auth.ts @@ -9,6 +9,7 @@ export type AuthPermissionCode = | 'RESERVATION_AUDIT_READ' | 'HOTEL_SWITCH' | 'SYSTEM_AUTH_READ' + | 'SYSTEM_ADMIN_CONSOLE_ACCESS' | 'SYSTEM_USER_MANAGE' | 'SYSTEM_ROLE_MANAGE' | 'SYSTEM_MENU_MANAGE' diff --git a/client/src/types/systemAdmin.ts b/client/src/types/systemAdmin.ts new file mode 100644 index 0000000..a6a2830 --- /dev/null +++ b/client/src/types/systemAdmin.ts @@ -0,0 +1,236 @@ +export interface PlatformPaginationResult { + page_num: number + page_size: number + total: number +} + +export interface PlatformPageResult { + items: T[] + page: PlatformPaginationResult +} + +export interface SystemAdminPageFilters { + keyword?: string + page_num?: number + page_size?: number +} + +export interface AdminUserListFilters extends SystemAdminPageFilters { + user_status?: string +} + +export interface AdminRoleListFilters extends SystemAdminPageFilters { + role_status?: string +} + +export interface AdminMenuListFilters extends SystemAdminPageFilters { + menu_status?: string +} + +export interface AdminHotelListFilters extends SystemAdminPageFilters { + hotel_status?: string +} + +export interface AdminUserRoleResult { + id: string + role_code: string + role_name: string + system_builtin: boolean +} + +export interface AdminUserHotelResult { + hotel_id: string + hotel_name: string + hotel_status: string | null + default_hotel: boolean +} + +export interface AdminUserListItemResult { + id: string + username: string + display_name: string + email: string | null + phone: string | null + user_status: string + super_admin: boolean + roles: AdminUserRoleResult[] + default_hotel_id: string | null + last_login_at: string | null + updated_at: string | null +} + +export interface AdminUserDetailResult extends AdminUserListItemResult { + hotels: AdminUserHotelResult[] + created_at: string | null +} + +export interface AdminPermissionResult { + id: string + permission_code: string + permission_name: string + permission_group: string + permission_status: string + system_builtin: boolean +} + +export interface AdminRoleListItemResult { + id: string + role_code: string + role_name: string + role_status: string + system_builtin: boolean + permission_count: number + user_count: number +} + +export interface AdminRoleDetailResult extends AdminRoleListItemResult { + permissions: AdminPermissionResult[] + created_at: string | null + updated_at: string | null +} + +export interface AdminMenuResult { + id: string + parent_id: string | null + menu_code: string + menu_name: string + menu_type: string + route_path: string | null + component_key: string | null + icon_key: string | null + permission_code: string | null + sort_order: number | null + visible: boolean + menu_status: string + known_route: boolean + created_at: string | null + updated_at: string | null +} + +export interface AdminHotelResult { + id: string + hotel_id: string + hotel_name: string + hotel_status: string + time_zone: string + sort_order: number | null + authorized_user_count: number + created_at: string | null + updated_at: string | null +} + +export interface AdminUserCreateRequest { + username: string + initial_password: string + display_name: string + email?: string + phone?: string + user_status: string + role_ids: string[] + hotel_ids: string[] + default_hotel_id: string +} + +export interface AdminUserUpdateRequest { + display_name: string + email?: string + phone?: string + user_status: string +} + +export interface AdminUserRoleAssignmentRequest { + role_ids: string[] +} + +export interface AdminUserHotelAssignmentRequest { + hotel_ids: string[] + default_hotel_id: string +} + +export interface AdminUserPasswordResetRequest { + new_password?: string +} + +export interface AdminUserPasswordResetResult { + user_id: string + username: string + temporary_password: string +} + +export interface AdminRoleCreateRequest { + role_code: string + role_name: string + role_status: string +} + +export interface AdminRoleUpdateRequest { + role_name: string + role_status: string +} + +export interface AdminRolePermissionAssignmentRequest { + permission_ids: string[] +} + +export interface AdminMenuCreateRequest { + parent_id?: string + menu_code: string + menu_name: string + menu_type: string + route_path?: string + component_key?: string + icon_key?: string + permission_code?: string + sort_order?: number + visible: boolean + menu_status: string +} + +export interface AdminMenuUpdateRequest { + parent_id?: string + menu_name: string + menu_type: string + route_path?: string + component_key?: string + icon_key?: string + permission_code?: string + sort_order?: number + visible: boolean + menu_status: string +} + +export interface AdminHotelCreateRequest { + hotel_id: string + hotel_name: string + time_zone: string + sort_order?: number +} + +export interface AdminHotelUpdateRequest { + hotel_name: string + time_zone: string + sort_order?: number +} + +export interface AdminHotelStatusUpdateRequest { + hotel_status: string +} + +export interface AdminAuditLogListFilters extends SystemAdminPageFilters { + target_type?: string + target_id?: string + action?: string +} + +export interface AdminAuditLogResult { + id: string + actor_user_id: string + actor_username: string + actor_display_name: string + target_type: string + target_id: string + action: string + before_snapshot_json: string | null + after_snapshot_json: string | null + occurred_at: string +} diff --git a/client/src/views/system/RouteNotFoundView.vue b/client/src/views/system/RouteNotFoundView.vue new file mode 100644 index 0000000..392b192 --- /dev/null +++ b/client/src/views/system/RouteNotFoundView.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/client/src/views/system/SystemAdminLayoutView.vue b/client/src/views/system/SystemAdminLayoutView.vue new file mode 100644 index 0000000..7844d87 --- /dev/null +++ b/client/src/views/system/SystemAdminLayoutView.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/client/src/views/system/SystemAuditsView.vue b/client/src/views/system/SystemAuditsView.vue new file mode 100644 index 0000000..ad6afe2 --- /dev/null +++ b/client/src/views/system/SystemAuditsView.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/client/src/views/system/SystemHotelsView.vue b/client/src/views/system/SystemHotelsView.vue new file mode 100644 index 0000000..46a2032 --- /dev/null +++ b/client/src/views/system/SystemHotelsView.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/client/src/views/system/SystemMenusView.vue b/client/src/views/system/SystemMenusView.vue new file mode 100644 index 0000000..38c494f --- /dev/null +++ b/client/src/views/system/SystemMenusView.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/client/src/views/system/SystemRolesView.vue b/client/src/views/system/SystemRolesView.vue new file mode 100644 index 0000000..9431c80 --- /dev/null +++ b/client/src/views/system/SystemRolesView.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/client/src/views/system/SystemUsersView.vue b/client/src/views/system/SystemUsersView.vue new file mode 100644 index 0000000..a5b85eb --- /dev/null +++ b/client/src/views/system/SystemUsersView.vue @@ -0,0 +1,697 @@ + + + + + diff --git a/client/src/views/system/system-admin.css b/client/src/views/system/system-admin.css new file mode 100644 index 0000000..a38a20e --- /dev/null +++ b/client/src/views/system/system-admin.css @@ -0,0 +1,309 @@ +.system-admin-panel { + display: grid; + gap: 18px; +} + +.filter-section { + padding-bottom: 20px; +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + padding: 18px 20px 0; +} + +.filter-grid label { + display: grid; + gap: 7px; +} + +.filter-grid span { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 700; +} + +.filter-grid input, +.filter-grid select, +.form-grid input, +.form-grid select, +.stacked-form input, +.stacked-form select { + min-height: 38px; + min-width: 0; + border: 1px solid var(--th-color-slate-200); + border-radius: var(--th-radius-sm); + background: var(--th-color-white); + color: var(--th-color-slate-900); + padding: 8px 10px; +} + +.filter-grid select[multiple], +.form-grid select[multiple], +.stacked-form select[multiple] { + min-height: 96px; +} + +.action-section { + padding-bottom: 20px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + padding: 18px 20px 0; +} + +.form-grid label, +.stacked-form label { + display: grid; + gap: 7px; +} + +.form-grid span, +.stacked-form span { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 800; +} + +.stacked-form { + display: grid; + gap: 12px; + border-top: 1px solid var(--th-color-slate-200); + padding-top: 14px; +} + +.form-message { + margin: 0; + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 700; +} + +.system-admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 18px; +} + +.text-button, +.row-action { + border: 0; + background: transparent; + color: var(--th-color-blue-600); + cursor: pointer; + font-size: 13px; + font-weight: 800; + padding: 0; + text-decoration: none; +} + +.empty-state { + min-height: 220px; + display: grid; + place-items: center; + color: var(--th-color-slate-500); + font-size: 13px; + padding: 24px; + text-align: center; +} + +.empty-state--compact { + min-height: 160px; +} + +.empty-state--error { + color: var(--th-color-danger); +} + +.table-section { + overflow: hidden; +} + +.table-wrap { + overflow-x: auto; + padding: 14px 20px 20px; +} + +table { + width: 100%; + min-width: 920px; + border-collapse: collapse; +} + +th, +td { + border-bottom: 1px solid var(--th-color-slate-200); + padding: 14px 10px; + text-align: left; + vertical-align: middle; +} + +th { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 800; +} + +td { + color: var(--th-color-slate-900); + font-size: 13px; +} + +td strong, +td small { + display: block; +} + +td small { + margin-top: 4px; + color: var(--th-color-slate-500); + font-size: 12px; +} + +.status-pill, +.code-chip { + display: inline-flex; + max-width: 100%; + min-height: 24px; + align-items: center; + border-radius: var(--th-radius-sm); + font-size: 12px; + font-weight: 800; + padding: 4px 8px; +} + +.status-pill { + background: var(--th-color-slate-100); + color: var(--th-color-slate-700); +} + +.code-chip { + overflow-wrap: anywhere; + background: var(--th-color-blue-50); + color: var(--th-color-blue-700); +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-top: 1px solid var(--th-color-slate-200); + padding: 14px 20px 18px; +} + +.page-size-control, +.pagination-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.page-size-control span, +.pagination-actions span { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 800; +} + +.page-size-control select { + min-height: 34px; + border: 1px solid var(--th-color-slate-200); + border-radius: var(--th-radius-sm); + background: var(--th-color-white); + color: var(--th-color-slate-900); + padding: 6px 8px; +} + +.primary-button, +.secondary-button { + min-height: 34px; + border: 1px solid var(--th-color-slate-200); + border-radius: var(--th-radius-sm); + cursor: pointer; + font-size: 12px; + font-weight: 800; + padding: 6px 12px; +} + +.primary-button { + align-self: end; + background: var(--th-color-blue-600); + border-color: var(--th-color-blue-600); + color: var(--th-color-white); +} + +.secondary-button { + background: var(--th-color-white); + color: var(--th-color-slate-700); +} + +.primary-button:disabled, +.secondary-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.detail-panel { + align-self: start; + overflow: hidden; +} + +.detail-list { + display: grid; + gap: 14px; + padding: 16px 20px 20px; +} + +.detail-list div { + display: grid; + gap: 5px; +} + +.detail-list span { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 800; +} + +.detail-list strong { + color: var(--th-color-slate-900); + font-size: 13px; +} + +.detail-list small { + color: var(--th-color-slate-500); + font-size: 12px; +} + +.permission-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +@media (max-width: 980px) { + .system-admin-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .filter-grid { + grid-template-columns: 1fr; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .pagination-bar { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 08d4865..de6c338 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -2,7 +2,7 @@ ## 1. 文档定位 -本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、S000/S999 特殊只读任务、历史 Message Notification 等第一版页面。 +本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、S000/S999 特殊只读任务、历史 Message Notification、系统管理后台等第一版页面。 ## 2. 项目开发注意事项 @@ -11,6 +11,7 @@ - 业务判断必须使用后端返回的稳定 code,不使用中文或英文展示文案做判断。 - 后端返回的时间点字段统一是带 `Z` 的 ISO 8601 UTC 时间,例如 `created_at`、`updated_at`、`received_at`、`last_updated_at`;前端展示时再按用户或酒店时区格式化。 - 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不要按 UTC 时间点自动换算日期。 +- 详细时间设计参考 `docs/project/backend-time-design.md`,不要把数据库 UTC 时间直接当酒店当地时间展示。 - 前端不得保存或传递后端 Secret、replay access key、Provider API Key、Oracle 凭证、AgentBus Token。 - 后端数据库 ID 未来应尽量以字符串形式给前端,避免 JavaScript 长整型精度问题;如果当前接口仍返回数字,前端不要自行做数学运算。 - 接口字段发生变化前,需要先更新本目录沟通文档或对应需求文档。 @@ -57,6 +58,12 @@ | `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | | `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 并调用 SuperAgent | 仅 dev/test 受控调试使用;会写入 SourceMessage Inbox,但不创建订单和任务。 | +| `GET/POST/PUT /api/admin/users...` | 系统管理用户维护 | 需要 Bearer token 和 `SYSTEM_USER_MANAGE`;用户 ID 返回字符串;禁用用户会撤销其 ACTIVE session。 | +| `GET/POST/PUT /api/admin/roles...` | 系统管理角色权限维护 | 需要 `SYSTEM_ROLE_MANAGE`;内置角色只读,自定义角色可新增、编辑和分配权限。 | +| `GET /api/admin/permissions` | 权限码只读列表 | 需要 `SYSTEM_ROLE_MANAGE`;前端只展示和选择已有权限码,不自行造权限码。 | +| `GET/POST/PUT /api/admin/menus...` | 系统管理菜单维护 | 需要 `SYSTEM_MENU_MANAGE`;允许保存未知路由,前端必须有未知路由兜底页。 | +| `GET/POST/PUT /api/admin/hotels...` | 系统管理酒店维护 | 需要 `HOTEL_MANAGE`;新增酒店默认 `DISABLED`,单酒店阶段不能启用第二家 `ACTIVE`。 | +| `GET /api/admin/audits` | 系统管理操作审计 | 需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`;用于查看管理后台写操作审计,不包含密码、token、secret。 | ### 5.1 本轮新增 / 修改接口说明 @@ -209,6 +216,41 @@ run_label: 可选调试标签 - 返回的 `uploaded_media[]`、`original_eml_oss_url`、`html_body_with_oss_urls`、`html_body_sanitized` 可能包含 OSS URL;前端不要写入普通日志、埋点、错误上报或 URL query。 - `superagent_parsed_json` 为空时,前端展示 `superagent_raw_answer` 和 `warnings[]`,不要假定 SuperAgent 总能返回 JSON。 +### 5.9 系统管理后台接口接入注意 + +系统管理后台 V1 已提供 `/system` 前端入口和 `/api/admin/**` 后端接口。所有管理接口都必须带 `Authorization: Bearer `,无 token 返回 401,已登录但缺少权限返回 403。 + +前端路由和按钮注意: + +- `/system` 入口需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`;子页面按 `SYSTEM_USER_MANAGE`、`SYSTEM_ROLE_MANAGE`、`SYSTEM_MENU_MANAGE`、`HOTEL_MANAGE` 展示。 +- 如果用户只有酒店管理权限,进入 `/system` 时应跳到 `/system/hotels`,不要固定跳 `/system/users`。 +- 系统管理入口只代表可进入后台,不代表拥有所有子页面操作权限;按钮仍需按具体权限控制。 +- 直接访问未知菜单路由时前端必须展示安全兜底页,不要让页面白屏。 + +接口分页和字段注意: + +- 分页统一使用 `page_num`、`page_size`,响应统一是 `{ items, page: { page_num, page_size, total } }`。 +- 后端 `BIGINT` ID 返回字符串,前端不要转成 JavaScript number。 +- 时间点字段是带 `Z` 的 UTC 时间,展示时按用户或酒店时区格式化。 +- 写操作失败时前端应展示后端 `message` 或 `error_code`,尤其是启用第二家 `ACTIVE` 酒店、禁用最后一家 `ACTIVE` 酒店、修改内置角色、用户名重复等 409 场景。 + +用户管理注意: + +- 新增用户必须传初始密码,后端只保存哈希。 +- 用户启用 / 禁用通过 `PUT /api/admin/users/{userId}` 的 `user_status` 完成,没有单独 enable / disable 路径。 +- 禁用用户会撤销该用户全部 ACTIVE session;前端若正用该用户 token,会在下一次 `/api/auth/me` 或业务请求时收到 401。 +- 重置密码接口 `POST /api/admin/users/{userId}/password-reset` 只在本次响应返回 `temporary_password`,前端不能写入日志、埋点、URL、localStorage 或错误上报。 +- 用户授权酒店必须全部是 `ACTIVE` 酒店,默认酒店必须在授权酒店列表内。 + +角色、菜单、酒店注意: + +- 内置角色 `system_builtin=true` 时只读,前端应禁用编辑和权限分配按钮;后端仍会返回 409 兜底。 +- 新增自定义角色后,用户需要重新登录或刷新 `/api/auth/me` 才能拿到最新权限上下文。 +- 新增菜单允许未知路由;未知路由可以保存,但正式开放可见前要确认前端页面已经存在或兜底页可接受。 +- 新增酒店默认 `DISABLED`,`hotel_id` 新增后不能修改。 +- 单酒店阶段只允许一家 `ACTIVE` 酒店,后端会拒绝启用第二家 `ACTIVE`,也会拒绝禁用最后一家 `ACTIVE`。 +- 系统管理写操作会写入 `platform_admin_audit_log`;审计接口 `GET /api/admin/audits` 可按 `target_type`、`target_id`、`action` 查询。 + ## 6. 不给前端直接调用的接口 - `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 @@ -221,5 +263,6 @@ run_label: 可选调试标签 ## 7. 需要持续提醒的后置事项 - 普通任务切换订单接口继续后置。 -- 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单和管理后台仍后置。 +- 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 +- 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入继续后置。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 16a11db..429b394 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -19,6 +19,7 @@ - SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 - Debug EML 上传到 SuperAgent 调试链路:受控上传 `.eml`、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并返回调试结果。 - 登录权限底座:支持用户名密码登录、登出、当前用户上下文、数据库 session token、可访问酒店、权限码和可见菜单。 +- 系统管理后台 V1:支持用户、角色权限、菜单、酒店和管理操作审计的受控维护接口与前端页面。 当前不要把以下能力当作已上线: @@ -28,10 +29,9 @@ - 业务前端页面展示邮件原文。 - OHIP / OPERA 或其他业务系统真实写操作。 - 普通任务切换订单接口。 -- 用户、角色、权限、菜单和酒店管理后台 CRUD。 - 现有业务接口强制登录和强制权限拦截。 - 业务审计 actor 全量迁移到当前登录用户。 -- Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;未接入正式用户权限前不要开放给普通用户。 +- Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;即使已有登录权限,也不要开放给普通用户。 ## 2. 上线前必须确认 @@ -44,6 +44,9 @@ - 生产默认不保存 AgentBus raw frame 样本。 - AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 - S000/S999 特殊入口结果上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。 +- 系统管理后台上线前,必须确认至少存在一个 `ACTIVE` 超级管理员账号,且该账号拥有 `SYSTEM_ADMIN_CONSOLE_ACCESS` 和各系统管理权限。 +- 单酒店阶段上线前,必须确认 `platform_hotel` 中只有一家 `ACTIVE` 酒店;新增酒店可以存在但应保持 `DISABLED`。 +- 管理后台启用后,不要继续把手工改库作为常规运营方式;用户、角色、菜单和酒店变更应通过 `/api/admin/**` 并写入管理审计。 - 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。 - 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。 @@ -78,10 +81,10 @@ - 当前第一版只做可选 Bearer token 解析,现有 Reservation / SourceMessage 业务接口仍不强制登录。 - `/api/auth/me` 和 `/api/auth/logout` 需要 `Authorization: Bearer `。 - 初始管理员 bootstrap 只以“启用状态超级管理员”为阻断条件;如果测试库或生产库只剩禁用超级管理员,应通过环境变量恢复一个可登录超级管理员后再排查账号运营问题。 -- 内置角色权限矩阵在启动时按代码同步,矩阵移除的旧权限关系会被清理;管理后台上线前不要手工给内置角色追加临时权限作为长期方案。 +- 内置角色权限矩阵在启动时按代码同步,矩阵移除的旧权限关系会被清理;管理后台 V1 也不允许修改内置角色权限,临时权限应通过自定义角色承载。 - 普通用户默认酒店由后端写入逻辑和数据库唯一索引共同保持单默认;V10 migration 会在建约束前把历史重复默认清理为每个用户保留 id 最大的一条。 - 单酒店阶段系统酒店由 `platform_hotel` 唯一 `ACTIVE` 酒店决定;V12 migration 会通过唯一索引阻止第二家 `ACTIVE` 酒店。上线前如果已有多家 `ACTIVE` 酒店,必须先调整数据,否则迁移或运行时解析会失败。 -- 管理后台还未上线时,不要把数据库手工改用户、角色、权限作为常规运营手段。 +- 管理后台 V1 写操作会记录 `platform_admin_audit_log`;重置密码只允许临时密码出现在本次响应中,不得进入日志、审计快照或前端持久化存储。 ### 3.3 SourceMessage @@ -201,6 +204,11 @@ - `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` - `server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql` +- `server/src/main/resources/db/migration/V12__enforce_single_active_platform_hotel.sql` + +当前 M006 系统管理相关 migration: + +- `server/src/main/resources/db/migration/V15__create_platform_admin_audit_log.sql` 上线前确认: @@ -213,6 +221,7 @@ - MySQL JDBC URL 建议明确 `serverTimezone=UTC`;部署容器和 JVM 也应使用 UTC,或至少确认应用代码所有入库时间均通过 UTC 时钟生成。 - AgentBus 邮件来源时间、SuperAgent HMAC timestamp、本系统 `created_at` / `updated_at` 等时间点统一按 UTC 理解;SourceMessage `received_at` 优先保存 AgentBus payload `received_at`,缺失时回退本系统接收时间,前端展示时再按用户或酒店时区格式化。 - 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不应因为 UTC 换算而自动前后偏移。 +- 详细时间设计、页面展示和按酒店本地日期筛选规则见 `docs/project/backend-time-design.md`。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 - 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 - 执行 V11 前,如果目标库已有手工造数或历史隐藏订单方案,必须确认是否需要回填 `order_visibility`;默认值 `VISIBLE` 会让历史订单继续出现在订单列表。 diff --git a/docs/project/requirements/M003-identity-access-hotel-menu-v1.md b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md index da660a4..600fd78 100644 --- a/docs/project/requirements/M003-identity-access-hotel-menu-v1.md +++ b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md @@ -36,9 +36,9 @@ - `AUTH_BOOTSTRAP_ADMIN_USERNAME` - `AUTH_BOOTSTRAP_ADMIN_PASSWORD` - `AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME` -- 系统中已存在超级管理员后,不再使用环境变量覆盖管理员账号或密码。 +- 系统中已存在启用状态的超级管理员后,不再使用环境变量覆盖管理员账号或密码;如果只存在禁用的超级管理员,环境变量仍可初始化一个可登录超级管理员,避免系统锁死。 - 第一期开启登录和权限底座,但不强制拦截现有业务接口。 -- 管理后台还没有做,必须明确后置。 +- 系统管理后台已由 M006 承接;M003 仍只描述登录、权限、酒店和菜单运行时底座。 ## 3. 核心目标 @@ -225,6 +225,7 @@ platform.security - 普通用户至少应有一个可访问酒店。 - 普通用户最多只能有一个默认酒店。 +- 后端写入用户酒店授权时,如果将某个酒店设为默认酒店,会同时清理该用户其他酒店默认标记;数据库通过生成列和唯一索引兜底约束同一用户最多一条 `default_hotel=1`。 - 超级管理员不需要为每个酒店插授权关系,默认可访问全部启用酒店。 - 业务接口收到 `hotel_id` 时,后续强制鉴权阶段必须校验当前用户是否可访问该酒店。 @@ -291,6 +292,7 @@ platform.security - `RESERVATION_OPERATOR` 需要查看邮件原文和附件外链来处理任务,因此第一版包含 `SOURCE_MESSAGE_ORIGINAL_READ`。 - `RESERVATION_VIEWER` 只读查看订单、任务、审计和来源消息安全摘要;不允许保存、确认、执行 OPERA 模拟,也不允许访问 Debug EML。 - `SYSTEM_DEBUG_EML_RUN` 第一版只授予 `SYSTEM_ADMIN`,避免普通业务用户触发 SuperAgent 调试链路。 +- 启动初始化会按上表同步内置角色权限矩阵:矩阵中新增的权限会补齐,矩阵中移除的旧关系会清理。后续如果管理后台允许人工改内置角色,需要先重新确认“代码矩阵”和“后台配置”的优先级。 第一期可内置菜单,需和当前前端路由保持一致: @@ -557,7 +559,7 @@ AUTH_TEST_SESSION_TTL_MINUTES=720 说明: -- 启动初始化发现系统中已经存在超级管理员后,不会继续用环境变量覆盖管理员用户名或密码。 +- 启动初始化发现系统中已经存在启用状态的超级管理员后,不会继续用环境变量覆盖管理员用户名或密码;禁用状态超级管理员不阻止首次可登录管理员恢复初始化。 - 数据库 `platform_user_session` 只保存 `token_hash`,不保存明文 token。 - 当前后端提供可选 Bearer token 解析,现有 Reservation / SourceMessage 业务接口第一版仍不强制登录。 diff --git a/docs/project/requirements/M006-system-admin-management-console-v1.md b/docs/project/requirements/M006-system-admin-management-console-v1.md new file mode 100644 index 0000000..6d10513 --- /dev/null +++ b/docs/project/requirements/M006-system-admin-management-console-v1.md @@ -0,0 +1,653 @@ +# M006 System Admin Management Console 系统管理后台 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.4 | +| 日期 | 2026-07-10 | +| 状态 | V1 已按当前代码实现更新 | +| 适用范围 | 用户、角色、权限、菜单、酒店和用户酒店授权的后台维护 | +| 依赖前置 | M003 登录权限与酒店菜单底座、M005 酒店上下文统一收口 | +| 主要读者 | 产品、后端、前端、测试、后续协作 agent | + +## 1. 文档定位 + +本文记录系统管理后台的整体建设方案。当前系统已经具备登录、session token、权限码、角色权限矩阵、可见菜单和可访问酒店等运行时底座,但这些数据主要由后端启动初始化和数据库表承载,尚不能通过前端页面进行日常维护。 + +本方案目标是把用户、角色、权限、菜单和酒店这些平台基础数据,从“只能依赖初始化或手工改库”推进到“通过受控后端接口和前端管理页面维护”。 + +该能力属于平台管理能力,不属于 `workflows.reservation`。业务模块只能消费当前用户、权限、菜单和酒店上下文,不应反向依赖管理后台实现。 + +## 2. 当前现状 + +### 2.1 已有后端能力 + +当前后端已有以下表结构: + +| 表 | 用途 | +| --- | --- | +| `platform_user` | 用户账号、密码哈希、状态和超级管理员标记 | +| `platform_user_session` | 数据库 session token,库里只保存 token hash | +| `platform_role` | 角色定义 | +| `platform_permission` | 权限码定义 | +| `platform_user_role` | 用户角色关系 | +| `platform_role_permission` | 角色权限关系 | +| `platform_hotel` | 酒店基础资料 | +| `platform_user_hotel` | 用户酒店授权和默认酒店 | +| `platform_menu` | 菜单定义、路由、图标、权限码和可见性 | + +当前后端已有登录接口: + +```text +POST /api/auth/login +GET /api/auth/me +POST /api/auth/logout +``` + +当前后端已有平台服务能力: + +- 登录成功返回 `access_token`、当前用户、权限码、酒店列表和可见菜单。 +- 可选解析 Bearer token,并将当前用户上下文放入请求线程。 +- 可判断用户是否拥有权限码。 +- 可判断用户是否可访问指定酒店。 +- 可按权限码查询可见菜单。 +- 启动时同步内置权限、内置角色、角色权限矩阵、内置菜单、默认酒店和首个超级管理员。 + +### 2.2 已有前端能力 + +当前前端已有以下能力: + +- 登录页。 +- token 存入 `sessionStorage`。 +- 启动时通过 `/api/auth/me` 恢复登录态。 +- 请求自动携带 `Authorization: Bearer `。 +- 根据 `menus[]` 渲染侧边栏菜单。 +- 根据路由 `meta.permission` 做页面级权限拦截。 +- 根据 `hotels[]` 和 `HOTEL_SWITCH` 权限展示酒店切换。 +- 任务详情部分按钮已做权限控制,例如编辑、确认、OPERA 模拟、审计查看。 + +### 2.3 当前缺口 + +当前缺口不是“没有权限体系”,而是“没有管理维护能力”: + +- 没有用户管理接口和页面。 +- 没有角色管理接口和页面。 +- 没有权限分配接口和页面。 +- 没有菜单管理接口和页面。 +- 没有酒店管理接口和页面。 +- 没有用户酒店授权管理接口和页面。 +- 管理类接口还没有强制登录和权限拦截。 +- 管理操作审计还没有落地。 +- 内置角色权限矩阵由代码启动同步,页面修改内置角色的边界尚未确认。 + +## 3. 核心目标 + +系统管理后台 V1 要解决以下问题: + +- 系统管理员可以在前端查看平台用户、角色、权限、菜单和酒店数据。 +- 系统管理员可以创建和维护普通用户。 +- 系统管理员可以给用户分配角色。 +- 系统管理员可以给普通用户授权酒店,并设置默认酒店。 +- 系统管理员可以查看角色拥有的权限,并在确认规则后维护角色权限。 +- 系统管理员可以维护菜单入口的显示、隐藏、排序、图标和权限绑定。 +- 系统管理员可以维护酒店基础资料和启停状态。 +- 所有管理接口必须强制登录,并按管理权限码拦截。 +- 所有写操作必须可审计、可回溯,不允许普通用户通过前端绕过权限。 + +### 3.1 补充决策和约束 + +以下内容用于避免开发时把 M003 / M005 的运行时底座误用成完整管理后台: + +- 管理后台接口第一版统一使用 `/api/admin` 前缀,和 `/api/system` 调试类接口区分。 +- 系统管理页面第一版使用子路由:`/system/users`、`/system/roles`、`/system/menus`、`/system/hotels`、`/system/audits`。 +- 管理接口必须显式强制登录和权限校验,不能依赖当前 `OptionalAuthTokenFilter` 的可选解析行为。 +- 后端应新增统一的管理接口鉴权辅助能力,例如 `requireLogin()`、`requirePermission(permissionCode)` 或 `AdminAuthorizationService`,避免每个 Controller 手写不同的 401 / 403 逻辑。 +- 单酒店阶段仍以 M005 为准:`platform_hotel` 只能有一家 `ACTIVE` 酒店。酒店管理第一版可以查看和维护酒店资料,但启用第二家 `ACTIVE` 酒店必须被后端拒绝。 +- 内置角色权限矩阵仍由代码启动同步。第一版管理后台不允许运营修改内置角色权限,否则会被启动同步覆盖,且容易造成权限预期不一致。 +- 系统管理入口新增 `SYSTEM_ADMIN_CONSOLE_ACCESS` 权限码。拥有任一系统管理能力的角色应同时持有该入口权限;子页面和接口仍按各自管理权限码拦截。 +- 菜单可见性只控制入口,不替代后端接口权限。前端隐藏菜单或按钮不能作为安全边界。 +- 第一版允许新增菜单配置,包括当前前端尚未注册的菜单;未知路由如果直接设为可见启用,前端需要有安全兜底,不应导致页面崩溃。 + +## 4. 非目标范围 + +第一版不做以下能力: + +- 不做外部 SSO、OIDC、LDAP、企业微信或短信登录。 +- 不做 MFA、多设备管理、登录设备踢出、密码找回。 +- 不让前端直接操作数据库。 +- 不让前端保存或传递任何后端 Secret。 +- 不把菜单隐藏当成后端权限校验。 +- 不允许运营随意新增后端没有实现的权限码。 +- 允许新增当前前端尚未注册的菜单配置,但未知路由不能被视为已可用页面;前端需要安全兜底,后续页面开发完成后再正式开放入口。 +- 不一次性完成所有 Reservation / SourceMessage 业务接口强制鉴权,该事项仍属于 M003 CP3 或后续安全收口。 + +## 5. 权限边界 + +系统管理后台至少使用以下权限码: + +| 权限码 | 管理范围 | +| --- | --- | +| `SYSTEM_USER_MANAGE` | 用户管理、用户角色、用户酒店授权 | +| `SYSTEM_ROLE_MANAGE` | 角色管理、角色权限分配 | +| `SYSTEM_MENU_MANAGE` | 菜单管理 | +| `HOTEL_MANAGE` | 酒店管理 | +| `SYSTEM_AUTH_READ` | 读取当前登录上下文 | +| `SYSTEM_ADMIN_CONSOLE_ACCESS` | 进入系统管理入口 | + +建议规则: + +- 进入系统管理入口需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`。 +- 用户管理页面需要 `SYSTEM_USER_MANAGE`。 +- 角色权限页面需要 `SYSTEM_ROLE_MANAGE`。 +- 菜单管理页面需要 `SYSTEM_MENU_MANAGE`。 +- 酒店管理页面需要 `HOTEL_MANAGE`。 +- 后端管理接口必须逐个校验权限,不依赖前端隐藏按钮。 +- 当前 `platform_menu.permission_code` 只支持单个权限码。系统管理入口已确认通过新增入口权限解决“任意管理权限可见”的建模问题。 + +入口权限建模决策: + +| 方案 | 说明 | 影响 | +| --- | --- | --- | +| 新增 `SYSTEM_ADMIN_CONSOLE_ACCESS` | 系统管理入口绑定该权限,拥有任一管理能力的角色都额外授予该入口权限 | 已确认采用 | +| 拆分多个系统管理菜单 | 用户、角色、菜单、酒店分别作为菜单入口,各自绑定对应权限 | 菜单更多,但不需要新增入口权限 | +| 菜单不绑定权限,前端子路由拦截 | `/system` 入口所有登录用户可见,进入后再按子页面权限拦截 | 不推荐,普通用户会看到无效入口 | + +## 6. 后端改动范围 + +### 6.1 新增管理接口模块 + +建议在现有平台模块内补管理接口,不单独创建业务工作流模块: + +```text +platform.identity.control +// 用户管理 Controller + +platform.access.control +// 角色和权限管理 Controller + +platform.navigation.control +// 菜单管理 Controller + +platform.hotel.control +// 酒店和用户酒店授权管理 Controller +``` + +中文说明: + +- 用户账号仍归 `platform.identity`。 +- 角色、权限和授权关系仍归 `platform.access`。 +- 菜单仍归 `platform.navigation`。 +- 酒店和用户酒店授权仍归 `platform.hotel`。 +- 管理接口第一版建议统一使用 `/api/admin/...` 前缀,避免和 `/api/system/...` 调试、运维、fixture 接口混在一起。 + +### 6.1.1 管理接口统一鉴权和错误响应 + +管理接口不能复用“可选登录态”的兼容策略,必须强制登录和权限校验。 + +建议统一规则: + +| 场景 | HTTP 状态 | 建议错误码 | 说明 | +| --- | --- | --- | --- | +| 未传 Bearer token | 401 | `ADMIN_AUTH_REQUIRED` | 管理后台必须先登录 | +| token 无效、过期或 session 已撤销 | 401 | `AUTH_SESSION_INVALID` | 可复用登录态失效语义 | +| 已登录但缺少权限码 | 403 | `ADMIN_PERMISSION_DENIED` | 返回缺少的权限码摘要,不返回敏感信息 | +| 请求参数错误 | 400 | `ADMIN_INVALID_REQUEST` | 字段校验失败 | +| 目标对象不存在 | 404 | `ADMIN_TARGET_NOT_FOUND` | 用户、角色、菜单、酒店不存在 | +| 状态冲突或唯一约束冲突 | 409 | `ADMIN_CONFLICT` | 用户名重复、启用第二家 ACTIVE 酒店等 | + +实现建议: + +- 在 `platform.security` 或对应管理模块下补统一鉴权服务,不在 Controller 里散落 token 解析逻辑。 +- Controller 只负责 HTTP 参数转换,Service 负责业务规则和事务。 +- ControllerAdvice 统一处理管理接口异常,错误响应不得包含密码、token、Secret 或原始敏感数据。 + +### 6.2 用户管理后端能力 + +第一版建议支持: + +| 能力 | 说明 | +| --- | --- | +| 用户分页列表 | 按用户名、展示名、状态、角色、酒店筛选 | +| 用户详情 | 返回基础信息、角色、授权酒店、默认酒店 | +| 新增用户 | 创建用户名、初始密码、展示名、联系方式、状态 | +| 编辑用户 | 修改展示名、邮箱、手机号、状态 | +| 禁用 / 启用用户 | 禁用后不能登录,并撤销该用户全部 ACTIVE session | +| 重置密码 | 管理员重置临时密码;密码不得出现在日志 | +| 分配角色 | 覆盖用户角色关系 | +| 分配酒店 | 覆盖普通用户可访问酒店,并设置默认酒店 | + +注意: + +- `username` 全局唯一。 +- 密码只保存单向哈希。 +- 超级管理员账号的禁用、降权和删除需要额外保护,避免系统锁死。 +- 用户 ID 返回前端时必须是字符串,前端不要转成 JavaScript number。 + +### 6.3 角色权限管理后端能力 + +第一版建议支持: + +| 能力 | 说明 | +| --- | --- | +| 角色列表 | 返回角色代码、名称、状态、是否内置、权限数量、用户数量 | +| 角色详情 | 返回角色基础信息和权限列表 | +| 权限列表 | 只读返回权限码、名称、分组、状态、是否内置 | +| 新增自定义角色 | 创建业务角色,角色代码稳定唯一 | +| 编辑角色 | 修改角色名称、状态 | +| 分配权限 | 覆盖角色权限关系 | + +内置角色边界已确认: + +- 当前 `SYSTEM_ADMIN`、`RESERVATION_OPERATOR`、`RESERVATION_VIEWER` 是内置角色。 +- 当前代码启动时会同步内置角色权限矩阵,并清理矩阵之外的旧关系。 +- 第一版内置角色只读,不允许通过页面修改内置角色权限。 +- 自定义角色允许新增、编辑和配置权限。 + +### 6.4 菜单管理后端能力 + +第一版建议支持: + +| 能力 | 说明 | +| --- | --- | +| 菜单列表 | 返回菜单代码、名称、路由、图标、权限、排序、可见性、状态 | +| 菜单详情 | 查看单个菜单完整配置 | +| 编辑菜单 | 修改名称、图标、排序、可见性、状态、权限码 | +| 新增菜单 | 第一版允许新增菜单配置,包括当前前端尚未注册的菜单 | +| 菜单排序 | 支持保存排序号 | + +建议限制: + +- `permission_code` 必须来自已启用权限码。 +- 第一版允许新增未知菜单,但未知 `route_path` 不能被视为已可用页面;前端路由不存在时需要展示安全兜底或跳转到无权限 / 未找到页面。 +- 新增菜单如果配置了未知路由,建议默认 `visible=false` 或 `menu_status=DISABLED`,由管理员在前端支持到位后再开放。 +- 菜单只决定入口可见性,不替代后端接口权限。 + +### 6.5 酒店管理后端能力 + +第一版建议支持: + +| 能力 | 说明 | +| --- | --- | +| 酒店列表 | 返回酒店 ID、名称、状态、时区、排序 | +| 新增酒店 | 创建业务酒店 ID、名称、时区 | +| 编辑酒店 | 修改名称、时区、排序 | +| 启用 / 禁用酒店 | 禁用后普通用户不可选择 | + +注意: + +- `hotel_id` 是业务数据隔离关键字段,不能随意修改。第一版建议新增后不允许修改 `hotel_id`。 +- 禁用酒店前需要确认是否已有 SourceMessage、Reservation、Task 等业务数据。 +- 超级管理员默认可访问全部启用酒店。 +- 普通用户只能访问 `platform_user_hotel` 授权酒店。 +- 单酒店阶段数据库已通过 V12 约束最多只有一家 `ACTIVE` 酒店。启用酒店接口必须先判断当前是否已有其他 `ACTIVE` 酒店;如果已有,应返回 409,而不是依赖数据库异常直接冒出。 +- 第一版允许新增酒店,但新增后默认创建为 `DISABLED`,避免新增即触发单酒店约束。 +- 禁用当前唯一 `ACTIVE` 酒店会影响系统默认酒店上下文解析。第一版禁止禁用最后一家 `ACTIVE` 酒店。 + +### 6.6 管理操作审计 + +建议新增或复用平台审计能力,至少记录: + +| 字段 | 说明 | +| --- | --- | +| actor_user_id | 操作用户 ID | +| actor_username | 操作用户名摘要 | +| target_type | 操作对象类型,例如 USER、ROLE、MENU、HOTEL | +| target_id | 操作对象 ID | +| action | 操作类型 | +| before_snapshot_json | 变更前摘要,不保存密码、token、secret | +| after_snapshot_json | 变更后摘要,不保存密码、token、secret | +| occurred_at | 操作 UTC 时间 | + +审计可以作为写操作正式开放前的必要前置。 + +## 7. 前端改动范围 + +### 7.1 系统管理入口 + +第一版新增一个系统管理入口: + +```text +/system +``` + +第一版已确认采用子路由: + +```text +/system/users +/system/roles +/system/menus +/system/hotels +/system/audits +``` + +第一版使用子路由,便于后续做页面权限、刷新保持位置和独立测试。 + +### 7.2 用户管理页面 + +页面建议包含: + +- 用户列表表格。 +- 状态、关键词、角色、酒店筛选。 +- 新增用户弹窗或独立页面。 +- 用户详情抽屉。 +- 角色分配区域。 +- 酒店授权区域。 +- 设置默认酒店操作。 +- 重置密码操作。 +- 启用 / 禁用操作。 + +用户表格建议字段: + +| 字段 | 说明 | +| --- | --- | +| 用户名 | 登录用户名 | +| 展示名 | 业务人员可读名称 | +| 状态 | ACTIVE / DISABLED | +| 超级管理员 | 是否超级管理员 | +| 角色 | 当前角色摘要 | +| 默认酒店 | 默认酒店 | +| 最近登录 | UTC 转本地展示 | +| 更新时间 | UTC 转本地展示 | + +### 7.3 角色权限页面 + +页面建议包含: + +- 角色列表。 +- 角色详情。 +- 权限分组勾选。 +- 内置角色只读提示。 +- 自定义角色新增和编辑。 + +权限建议按分组展示: + +| 分组 | 示例权限 | +| --- | --- | +| SYSTEM | 系统用户、角色、菜单、Debug | +| HOTEL | 酒店管理、酒店切换 | +| RESERVATION | 订单、任务、确认、OPERA、审计 | +| SOURCE_MESSAGE | 邮件摘要、邮件原文 | + +### 7.4 菜单管理页面 + +页面建议包含: + +- 菜单树或菜单表格。 +- 菜单名称、路由、图标、权限码、排序、状态、是否可见。 +- 路由输入、已知路由提示和未知路由安全兜底。 +- 权限码下拉选择。 +- 预览当前菜单可见效果。 + +### 7.5 酒店管理页面 + +页面建议包含: + +- 酒店列表。 +- 新增酒店。 +- 编辑酒店名称、时区、排序。 +- 启用 / 禁用酒店。 +- 查看授权用户数量。 + +### 7.6 前端服务和类型 + +建议新增: + +```text +client/src/services/systemAdminService.ts +client/src/types/systemAdmin.ts +``` + +前端请求仍统一复用当前 `httpClient`,由它自动携带 Bearer token。 + +## 8. 接口草案 + +正式开发前需要再细化字段、分页格式和错误码。第一版接口前缀建议统一使用 `/api/admin`,先按以下方向设计。 + +通用要求: + +- 所有 `/api/admin/**` 接口必须携带 `Authorization: Bearer `。 +- 无 token 或 token 无效返回 401。 +- 已登录但缺少对应管理权限返回 403。 +- 所有 `BIGINT` ID 返回前端时使用字符串,前端不得转换为 JavaScript number。 +- 时间点字段继续使用带 `Z` 的 ISO 8601 UTC 时间。 +- 分页请求和响应统一当前 Reservation 风格:请求参数使用 `page_num`、`page_size`;响应使用 `{ "items": [...], "page": { "page_num": 1, "page_size": 20, "total": 0 } }`。 + +### 8.1 用户管理 + +```text +GET /api/admin/users +GET /api/admin/users/{userId} +POST /api/admin/users +PUT /api/admin/users/{userId} +POST /api/admin/users/{userId}/password-reset +PUT /api/admin/users/{userId}/roles +PUT /api/admin/users/{userId}/hotels +``` + +说明:用户启用 / 禁用第一版通过 `PUT /api/admin/users/{userId}` 的 `user_status` 字段完成;禁用用户会撤销该用户全部 ACTIVE session。 + +### 8.2 角色权限管理 + +```text +GET /api/admin/roles +GET /api/admin/roles/{roleId} +POST /api/admin/roles +PUT /api/admin/roles/{roleId} +PUT /api/admin/roles/{roleId}/permissions +GET /api/admin/permissions +``` + +说明:角色启用 / 禁用第一版通过 `PUT /api/admin/roles/{roleId}` 的 `role_status` 字段完成;内置角色只读,编辑和权限分配会返回冲突错误。 + +### 8.3 菜单管理 + +```text +GET /api/admin/menus +GET /api/admin/menus/{menuId} +POST /api/admin/menus +PUT /api/admin/menus/{menuId} +``` + +说明:菜单排序第一版通过单条菜单的 `sort_order` 字段保存;路由选项接口未做,前端允许手工输入未知路由并通过兜底页保护。 + +### 8.4 酒店管理 + +```text +GET /api/admin/hotels +GET /api/admin/hotels/{hotelId} +POST /api/admin/hotels +PUT /api/admin/hotels/{hotelId} +PUT /api/admin/hotels/{hotelId}/status +``` + +说明:新增酒店默认 `DISABLED`;状态切换统一走 `/status`,启用第二家 ACTIVE 酒店和禁用最后一家 ACTIVE 酒店都会返回冲突错误。 + +### 8.5 管理审计 + +```text +GET /api/admin/audits +``` + +说明:写操作会记录 `platform_admin_audit_log`,审计查询支持 `target_type`、`target_id`、`action`、`page_num`、`page_size`。 + +## 9. 推荐分期 + +### CP4-1:系统管理只读页 + +目标:先让管理员能通过前端看见当前用户、角色、权限、菜单和酒店数据。 + +范围: + +- 后端新增只读查询接口。 +- 前端新增系统管理入口和只读页面。 +- 系统设置菜单从隐藏调整为有权限可见,入口权限使用 `SYSTEM_ADMIN_CONSOLE_ACCESS`。 +- 管理接口强制登录和权限校验。 +- 不做新增、编辑、禁用、分配。 + +验收标准: + +- 系统管理员登录后能看到系统管理入口。 +- 非系统管理权限用户看不到入口,直接访问路由也会被拦截。 +- 用户、角色、权限、菜单、酒店列表能正常展示。 +- 后端接口无 token 返回 401,无权限返回 403。 + +当前实现状态:已完成。 + +### CP4-2:用户管理写操作 + +目标:支持日常账号维护。 + +范围: + +- 新增用户。 +- 编辑用户基础信息。 +- 启用 / 禁用用户。 +- 重置密码。 +- 分配角色。 +- 分配酒店和默认酒店。 +- 写操作审计。 + +验收标准: + +- 新用户可用初始密码登录。 +- 禁用用户不能登录。 +- 普通用户只能看到授权酒店。 +- 默认酒店最多只有一个。 + +当前实现状态:已完成。新增用户、编辑状态、重置密码、覆盖角色、覆盖酒店授权已接入前后端;禁用用户会撤销 ACTIVE session。 + +### CP4-3:角色权限管理 + +目标:支持自定义业务角色。 + +范围: + +- 新增自定义角色。 +- 编辑自定义角色。 +- 启用 / 禁用自定义角色。 +- 分配权限。 +- 内置角色只读或按确认后的规则开放。 + +验收标准: + +- 自定义角色权限变更后,用户重新登录或刷新上下文后生效。 +- 无权限码的页面入口和按钮不可见,后端接口仍能拦截。 + +当前实现状态:已完成。自定义角色可新增、编辑和分配权限;内置角色只读。 + +### CP4-4:菜单和酒店管理 + +目标:支持菜单入口和酒店基础资料维护。 + +范围: + +- 编辑菜单名称、图标、排序、可见性、状态和权限码。 +- 酒店新增、编辑、启用、禁用。 +- 路由配置、未知路由兜底和权限码选择。 + +验收标准: + +- 菜单配置变更后,用户刷新 `/api/auth/me` 可以拿到新菜单。 +- 禁用酒店后普通用户不可再选择该酒店。 +- 允许配置当前前端尚不存在的菜单路由,但前端必须安全兜底,不能因为未知路由导致页面崩溃。 + +当前实现状态:已完成。菜单支持新增和编辑;酒店支持新增、编辑和状态切换;前端提供未知路由兜底页。 + +### CP4-4b:管理操作审计页 + +目标:系统管理员可以查看管理后台写操作审计。 + +范围: + +- 后端新增 `GET /api/admin/audits`。 +- 前端新增 `/system/audits`。 +- 审计页按对象类型、对象 ID 和操作类型筛选。 + +当前实现状态:已完成。 + +### CP4-5:业务接口强制权限收口 + +目标:把管理后台和业务接口的权限模型闭环。 + +范围: + +- Reservation 查询接口强制登录和酒店权限。 +- Reservation 写接口强制具体操作权限。 +- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- 业务审计 actor 全面迁移到当前用户。 + +该阶段和 M003 CP3、M005 酒店上下文统一收口有关,建议单独拆文档和任务。 + +## 10. 风险和约束 + +| 风险 | 说明 | 建议 | +| --- | --- | --- | +| 内置角色被启动同步覆盖 | 当前代码会按矩阵同步内置角色权限 | 第一版内置角色只读,自定义角色可编辑 | +| 菜单配置了不存在路由 | 前端无法渲染或跳转失败 | 允许保存未知路由,但前端必须安全兜底;未知路由正式开放前建议保持隐藏或禁用 | +| 前端隐藏按钮被绕过 | 用户可直接调用接口 | 后端所有管理接口必须强制鉴权和权限校验 | +| 可选登录过滤器被误认为强制鉴权 | 当前过滤器只解析 token,不拦截业务接口 | `/api/admin/**` 必须显式 require login / permission | +| 禁用超级管理员导致锁死 | 系统可能没有可登录管理员 | 禁止禁用最后一个启用超级管理员 | +| 用户默认酒店冲突 | 普通用户可能有多个默认酒店 | 后端服务和数据库唯一索引双重保证 | +| 单酒店阶段启用第二家酒店 | V12 会阻止多家 ACTIVE 酒店,业务层若不拦截会暴露数据库异常 | 酒店启用前主动检查,返回 409 | +| 系统设置菜单只有单权限码 | `platform_menu.permission_code` 当前只支持一个权限码 | 新增 `SYSTEM_ADMIN_CONSOLE_ACCESS` 作为统一入口权限 | +| 酒店禁用影响历史业务数据 | 历史订单、邮件仍属于该酒店 | 禁用只影响新选择和访问,不删除历史数据 | +| 密码或 token 泄漏 | 管理后台涉及重置密码 | 密码只返回一次,不进日志、审计和错误响应 | + +## 11. 已确认决策 + +| 问题 | 建议 | 状态 | +| --- | --- | --- | +| 管理接口前缀用 `/api/admin` 还是 `/api/system`? | 使用 `/api/admin`,语义更清楚 | 已确认 | +| 第一版系统管理页面用 Tab 还是子路由? | 使用子路由 | 已确认 | +| 系统管理入口权限如何建模? | 新增 `SYSTEM_ADMIN_CONSOLE_ACCESS`,由具备任一管理能力的角色持有 | 已确认 | +| 内置角色是否允许页面修改权限? | 第一版只读 | 已确认 | +| 是否允许新增菜单? | 允许新增菜单配置,包括未知菜单;未知路由需要前端安全兜底 | 已确认 | +| 是否允许修改 `hotel_id`? | 新增后不可修改 | 已确认 | +| 单酒店阶段是否允许新增酒店? | 允许新增但默认 `DISABLED`,不允许启用第二家 `ACTIVE` | 已确认 | +| 是否允许禁用最后一家 `ACTIVE` 酒店? | 禁止,避免系统酒店上下文无法解析 | 已确认 | +| 禁用用户时是否撤销已有 session? | 撤销该用户全部 ACTIVE session | 已确认 | +| 重置密码是否要求用户下次登录修改? | 第一版先不强制,后续安全增强 | 已确认 | +| 管理操作审计用新平台审计表还是复用现有审计表? | 新增平台管理审计表 | 已确认 | +| 管理接口分页格式是否统一为当前 Reservation 风格? | 统一使用 `page_num`、`page_size`、`items`、`page` | 已确认 | + +## 12. 建议第一个最小 checkpoint + +建议第一个最小 checkpoint 为: + +```text +checkpoint-M006-CP4-1-system-admin-readonly-console +``` + +范围只做只读: + +- 后端新增用户、角色、权限、菜单、酒店只读接口。 +- 管理接口强制登录和权限校验。 +- 前端新增系统管理入口和只读页面。 +- 不做新增、编辑、禁用、分配、重置密码。 + +这样可以先验证: + +- 当前数据模型是否足够支撑页面。 +- 菜单和权限入口是否顺畅。 +- 管理后台的页面结构是否符合实际使用。 +- 后续写操作是否需要补表或调整字段。 + +## 13. 目标模式提示词建议 + +如果要进入目标模式开发只读版,可以这样说: + +```text +目标:实现 M006 CP4-1 系统管理只读页。 + +要求: +1. 只做用户、角色、权限、菜单、酒店的只读查询,不做新增、编辑、删除、禁用、分配。 +2. 后端新增管理查询接口,接口必须强制登录,并按管理权限码校验。 +3. 前端新增系统管理入口和只读页面,按当前用户权限显示入口和页面。 +4. 新增 `SYSTEM_ADMIN_CONSOLE_ACCESS` 入口权限,系统设置菜单从隐藏调整为绑定该权限后可见。 +5. 不改变现有 Reservation / SourceMessage 业务接口鉴权策略。 +6. 不开放内置角色页面编辑;只允许为系统管理入口补必要的内置权限和菜单初始化。 +7. 不提交真实密码、token、业务数据或构建产物。 +8. 补充必要测试。 +9. 完成后运行相关后端和前端测试,不提交,先给我看改动。 +``` diff --git a/docs/superpowers/plans/2026-07-10-m006-system-admin-v1.md b/docs/superpowers/plans/2026-07-10-m006-system-admin-v1.md new file mode 100644 index 0000000..a47dd09 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-m006-system-admin-v1.md @@ -0,0 +1,206 @@ +# M006 System Admin Management Console V1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 M006 系统管理后台 V1,让系统管理员可以通过受控接口和前端页面管理用户、角色权限、菜单、酒店和用户酒店授权。 + +**Architecture:** 后端继续使用 `platform.identity`、`platform.access`、`platform.navigation`、`platform.hotel` 分模块承载管理能力,新增平台管理审计和统一管理鉴权服务。前端新增 `/system/*` 子路由和系统管理页面,复用现有 `httpClient`、登录态和菜单权限模型。 + +**Tech Stack:** Java 17、Spring Boot 3.5、MyBatis-Plus、Flyway、JUnit 5、Vue 3、TypeScript、Pinia、Vitest。 + +## Global Constraints + +- 管理接口统一使用 `/api/admin/**`。 +- 管理接口必须强制登录和权限校验,不能依赖 `OptionalAuthTokenFilter` 的兼容行为。 +- 系统管理入口使用 `SYSTEM_ADMIN_CONSOLE_ACCESS` 权限码。 +- 分页请求使用 `page_num`、`page_size`,响应使用 `{ items, page: { page_num, page_size, total } }`。 +- 内置角色第一版只读,自定义角色可管理。 +- 单酒店阶段允许新增酒店但默认 `DISABLED`,禁止启用第二家 `ACTIVE`,禁止禁用最后一家 `ACTIVE`。 +- `hotel_id` 新增后不可修改。 +- 新增菜单允许未知路由,但未知路由前端必须安全兜底。 +- 写操作必须写入独立平台管理审计表,审计不得保存密码、token、secret。 +- 后端代码遵守 `control`、`service`、`service.impl`、`domain`、`mapper`、`repository`、`common.request/result/dto/enums` 包结构。 +- Controller、Service、ServiceImpl 方法必须有中文注释;Entity 字段必须有中文注释。 + +--- + +### Task 1: M006 基础权限、菜单入口和管理鉴权 + +**Files:** +- Modify: `server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java` +- Modify: `server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuCode.java` +- Modify: `server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/security/service/AdminAuthorizationService.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationServiceImpl.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationException.java` +- Test: `server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java` +- Test: `server/src/test/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationServiceImplTest.java` + +**Interfaces:** +- Produces: `AdminAuthorizationService.requirePermission(String permissionCode)` returns current `AuthenticatedUserContext` or throws an admin authorization exception. +- Produces: `SYSTEM_ADMIN_CONSOLE_ACCESS` permission code and visible `/system` menu. + +- [x] Add `SYSTEM_ADMIN_CONSOLE_ACCESS` to platform permission enum. +- [x] Change `SYSTEM_SETTINGS` menu to visible and bind `SYSTEM_ADMIN_CONSOLE_ACCESS`. +- [x] Include `SYSTEM_ADMIN_CONSOLE_ACCESS` in `SYSTEM_ADMIN` built-in permissions. +- [x] Create admin authorization service with 401 and 403 semantics. +- [x] Add tests for missing token, missing permission and successful authorization. +- [x] Run focused backend tests. + +### Task 2: CP4-1 后端只读查询接口 + +**Files:** +- Create/Modify under `platform.identity.common.result/request/service/control/repository` +- Create/Modify under `platform.access.common.result/request/service/control/repository` +- Create/Modify under `platform.navigation.common.result/request/service/control/repository` +- Create/Modify under `platform.hotel.common.result/request/service/control/repository` +- Test: admin read-only controller tests. + +**Interfaces:** +- Produces: `GET /api/admin/users` +- Produces: `GET /api/admin/users/{userId}` +- Produces: `GET /api/admin/roles` +- Produces: `GET /api/admin/roles/{roleId}` +- Produces: `GET /api/admin/permissions` +- Produces: `GET /api/admin/menus` +- Produces: `GET /api/admin/menus/{menuId}` +- Produces: `GET /api/admin/hotels` +- Produces: `GET /api/admin/hotels/{hotelId}` + +- [x] Add repository list/detail methods with pagination where needed. +- [x] Add Service interfaces and implementations for read-only admin queries. +- [x] Add Controller classes with Chinese method comments. +- [x] Enforce management permissions per endpoint. +- [x] Return string IDs and UTC time strings. +- [x] Add MockMvc tests for 401, 403 and successful list responses. +- [x] Run focused backend tests. + +### Task 3: CP4-1 前端只读系统管理页面 + +**Files:** +- Modify: `client/src/types/auth.ts` +- Modify: `client/src/router/index.ts` +- Modify: `client/src/layouts/ReservationAppShell.vue` +- Create: `client/src/types/systemAdmin.ts` +- Create: `client/src/services/systemAdminService.ts` +- Create: `client/src/views/system/SystemAdminLayoutView.vue` +- Create: `client/src/views/system/SystemUsersView.vue` +- Create: `client/src/views/system/SystemRolesView.vue` +- Create: `client/src/views/system/SystemMenusView.vue` +- Create: `client/src/views/system/SystemHotelsView.vue` +- Modify: locale files under `client/src/i18n/locales/` +- Test: front-end route/service/view tests. + +**Interfaces:** +- Consumes: Task 2 admin read-only endpoints. +- Produces: visible `/system/*` routes and read-only admin tables. + +- [x] Add `SYSTEM_ADMIN_CONSOLE_ACCESS` to auth permission type. +- [x] Add `/system` redirect and `/system/*` child routes. +- [x] Add system menu label and known route fallback. +- [x] Implement API service and types. +- [x] Implement four read-only pages with loading, error and empty states. +- [x] Add frontend tests for route permission and API parameter mapping. +- [x] Run frontend typecheck and tests. + +### Task 4: 平台管理审计底座 + +**Files:** +- Create: `server/src/main/resources/db/migration/V15__create_platform_admin_audit_log.sql` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/domain/PlatformAdminAuditLogEntity.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/mapper/PlatformAdminAuditLogMapper.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/PlatformAdminAuditLogRepository.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/MybatisPlatformAdminAuditLogRepository.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/common/dto/PlatformAdminAuditLogDraft.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/service/PlatformAdminAuditLogService.java` +- Create: `server/src/main/java/cn/nianxx/thhotel/platform/audit/service/impl/PlatformAdminAuditLogServiceImpl.java` +- Test: audit migration/repository/service tests. + +**Interfaces:** +- Produces: `record(PlatformAdminAuditLogDraft draft)` for management write operations. + +- [x] Add migration with utf8mb4_bin and Chinese comments. +- [x] Add Entity, Mapper, Repository, Service. +- [x] Ensure snapshots exclude password, token and secret. +- [x] Add tests for audit insert and secret-safe snapshots. +- [x] Run focused backend tests. + +### Task 5: CP4-2 用户管理写操作 + +**Files:** +- Modify identity repository/service/control request/result classes. +- Modify access and hotel repositories for user-role and user-hotel overwrite operations. +- Test: user management controller/service tests. + +**Interfaces:** +- Produces: `POST /api/admin/users` +- Produces: `PUT /api/admin/users/{userId}` +- Produces: `POST /api/admin/users/{userId}/enable` +- Produces: `POST /api/admin/users/{userId}/disable` +- Produces: `POST /api/admin/users/{userId}/password-reset` +- Produces: `PUT /api/admin/users/{userId}/roles` +- Produces: `PUT /api/admin/users/{userId}/hotels` + +- [x] Implement create/edit user. +- [x] Implement enable/disable with last active super admin protection. +- [x] Revoke all ACTIVE sessions when disabling a user. +- [x] Implement password reset returning temporary password once. +- [x] Implement role overwrite and hotel overwrite with single default hotel. +- [x] Record admin audit for every write operation. +- [x] Add tests for success, 401, 403 and conflict paths. + +### Task 6: CP4-3 角色权限写操作 + +**Files:** +- Modify access repository/service/control request/result classes. +- Test: role management controller/service tests. + +**Interfaces:** +- Produces: `POST /api/admin/roles` +- Produces: `PUT /api/admin/roles/{roleId}` +- Produces: `POST /api/admin/roles/{roleId}/enable` +- Produces: `POST /api/admin/roles/{roleId}/disable` +- Produces: `PUT /api/admin/roles/{roleId}/permissions` + +- [x] Implement custom role create/edit/status changes. +- [x] Reject edits to built-in role permissions. +- [x] Implement permission overwrite for custom roles. +- [x] Record admin audit for every write operation. +- [x] Add tests for built-in role rejection and custom role updates. + +### Task 7: CP4-4 菜单和酒店写操作 + +**Files:** +- Modify navigation repository/service/control request/result classes. +- Modify hotel repository/service/control request/result classes. +- Test: menu and hotel management controller/service tests. + +**Interfaces:** +- Produces: `POST /api/admin/menus` +- Produces: `PUT /api/admin/menus/{menuId}` +- Produces: `PUT /api/admin/menus/sort-order` +- Produces: `GET /api/admin/menu-route-options` +- Produces: `POST /api/admin/hotels` +- Produces: `PUT /api/admin/hotels/{hotelId}` +- Produces: `POST /api/admin/hotels/{hotelId}/enable` +- Produces: `POST /api/admin/hotels/{hotelId}/disable` + +- [x] Implement menu create/edit/sort with enabled permission validation. +- [x] Allow unknown route persistence while returning route safety hints. +- [x] Implement hotel create as DISABLED and reject hotel_id edits. +- [x] Reject enabling second ACTIVE hotel and disabling last ACTIVE hotel. +- [x] Record admin audit for every write operation. +- [x] Add tests for menu unknown route and hotel single-active constraints. + +### Task 8: Full verification, docs, review and commit + +**Files:** +- Modify: `docs/project/requirements/M006-system-admin-management-console-v1.md` +- Modify: front-back communication docs if API contracts change. + +- [x] Update M006 document with implemented endpoints and remaining later work. +- [x] Run backend focused tests and full backend test when feasible. +- [x] Run frontend typecheck and test. +- [x] Perform code review focused on security, permissions, audit and data consistency. +- [x] Stage only M006-related files. +- [x] Commit with Chinese commit message. diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index e91aa79..57a2f67 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -14,6 +14,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; "cn.nianxx.thhotel.platform.access.mapper", "cn.nianxx.thhotel.platform.hotel.mapper", "cn.nianxx.thhotel.platform.navigation.mapper", + "cn.nianxx.thhotel.platform.audit.mapper", "cn.nianxx.thhotel.workflows.reservation.mapper", "cn.nianxx.thhotel.integrations.ai.superagent.mapper" }) diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java index 5f37083..9951b54 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -18,5 +18,6 @@ public enum PlatformPermissionCode { SYSTEM_ROLE_MANAGE, SYSTEM_MENU_MANAGE, HOTEL_MANAGE, + SYSTEM_ADMIN_CONSOLE_ACCESS, SYSTEM_DEBUG_EML_RUN } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleCreateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleCreateRequest.java new file mode 100644 index 0000000..e17edff --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleCreateRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.access.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台新增自定义角色请求。 + */ +public record AdminRoleCreateRequest( + /** 稳定角色代码,全局唯一。 */ + @JsonProperty("role_code") + String roleCode, + /** 角色展示名称。 */ + @JsonProperty("role_name") + String roleName, + /** 角色状态,默认 ACTIVE。 */ + @JsonProperty("role_status") + String roleStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRolePermissionAssignmentRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRolePermissionAssignmentRequest.java new file mode 100644 index 0000000..d0f5565 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRolePermissionAssignmentRequest.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.access.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 管理后台覆盖角色权限请求。 + */ +public record AdminRolePermissionAssignmentRequest( + /** 权限内部 ID 列表。 */ + @JsonProperty("permission_ids") + List permissionIds +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleUpdateRequest.java new file mode 100644 index 0000000..d665e75 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/request/AdminRoleUpdateRequest.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.platform.access.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台编辑自定义角色请求。 + */ +public record AdminRoleUpdateRequest( + /** 角色展示名称。 */ + @JsonProperty("role_name") + String roleName, + /** 角色状态:ACTIVE、DISABLED。 */ + @JsonProperty("role_status") + String roleStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminPermissionResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminPermissionResult.java new file mode 100644 index 0000000..7b29662 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminPermissionResult.java @@ -0,0 +1,27 @@ +package cn.nianxx.thhotel.platform.access.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台权限码结果。 + */ +public record AdminPermissionResult( + /** 权限内部 ID 字符串。 */ + String id, + /** 稳定权限代码。 */ + @JsonProperty("permission_code") + String permissionCode, + /** 权限展示名称。 */ + @JsonProperty("permission_name") + String permissionName, + /** 权限分组。 */ + @JsonProperty("permission_group") + String permissionGroup, + /** 权限状态。 */ + @JsonProperty("permission_status") + String permissionStatus, + /** 是否系统内置权限。 */ + @JsonProperty("system_builtin") + Boolean systemBuiltin +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleDetailResult.java new file mode 100644 index 0000000..c67e589 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleDetailResult.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.platform.access.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 管理后台角色详情。 + */ +public record AdminRoleDetailResult( + /** 角色内部 ID 字符串。 */ + String id, + /** 稳定角色代码。 */ + @JsonProperty("role_code") + String roleCode, + /** 角色展示名称。 */ + @JsonProperty("role_name") + String roleName, + /** 角色状态。 */ + @JsonProperty("role_status") + String roleStatus, + /** 是否系统内置角色。 */ + @JsonProperty("system_builtin") + Boolean systemBuiltin, + /** 创建 UTC 时间。 */ + @JsonProperty("created_at") + OffsetDateTime createdAt, + /** 更新 UTC 时间。 */ + @JsonProperty("updated_at") + OffsetDateTime updatedAt, + /** 角色拥有的权限。 */ + List permissions, + /** 使用该角色的用户数量。 */ + @JsonProperty("user_count") + long userCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleListItemResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleListItemResult.java new file mode 100644 index 0000000..0e159a8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/result/AdminRoleListItemResult.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.platform.access.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台角色列表项。 + */ +public record AdminRoleListItemResult( + /** 角色内部 ID 字符串。 */ + String id, + /** 稳定角色代码。 */ + @JsonProperty("role_code") + String roleCode, + /** 角色展示名称。 */ + @JsonProperty("role_name") + String roleName, + /** 角色状态。 */ + @JsonProperty("role_status") + String roleStatus, + /** 是否系统内置角色。 */ + @JsonProperty("system_builtin") + Boolean systemBuiltin, + /** 权限数量。 */ + @JsonProperty("permission_count") + long permissionCount, + /** 用户数量。 */ + @JsonProperty("user_count") + long userCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminPermissionController.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminPermissionController.java new file mode 100644 index 0000000..e4a44ef --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminPermissionController.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.platform.access.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.access.common.result.AdminPermissionResult; +import cn.nianxx.thhotel.platform.access.service.AdminAccessManagementService; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台权限 Controller。权限码第一版只读,不允许运营随意新增。 + */ +@RestController +@RequestMapping("/api/admin/permissions") +public class AdminPermissionController { + + private final AdminAuthorizationService authorizationService; + private final AdminAccessManagementService accessManagementService; + + /** + * 注入管理鉴权和角色权限管理服务。 + */ + public AdminPermissionController( + AdminAuthorizationService authorizationService, + AdminAccessManagementService accessManagementService) { + this.authorizationService = authorizationService; + this.accessManagementService = accessManagementService; + } + + /** + * 查询全部权限码,需要角色管理权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public List listPermissions() { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.listPermissions(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminRoleController.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminRoleController.java new file mode 100644 index 0000000..66a2bfb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/control/AdminRoleController.java @@ -0,0 +1,98 @@ +package cn.nianxx.thhotel.platform.access.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleCreateRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRolePermissionAssignmentRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleUpdateRequest; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleDetailResult; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleListItemResult; +import cn.nianxx.thhotel.platform.access.service.AdminAccessManagementService; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台角色 Controller。提供角色只读查询入口。 + */ +@RestController +@RequestMapping("/api/admin/roles") +public class AdminRoleController { + + private final AdminAuthorizationService authorizationService; + private final AdminAccessManagementService accessManagementService; + + /** + * 注入管理鉴权和角色权限管理服务。 + */ + public AdminRoleController( + AdminAuthorizationService authorizationService, + AdminAccessManagementService accessManagementService) { + this.authorizationService = authorizationService; + this.accessManagementService = accessManagementService; + } + + /** + * 分页查询角色列表,需要角色管理权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listRoles( + @RequestParam(required = false) String keyword, + @RequestParam(name = "role_status", required = false) String roleStatus, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.queryRoles(keyword, roleStatus, pageNum, pageSize); + } + + /** + * 查询角色详情,需要角色管理权限。 + */ + @GetMapping(value = "/{roleId}", produces = MediaType.APPLICATION_JSON_VALUE) + public AdminRoleDetailResult getRole(@PathVariable String roleId) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.getRole(roleId); + } + + /** + * 新增自定义角色,需要角色管理权限。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminRoleDetailResult createRole(@RequestBody(required = false) AdminRoleCreateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.createRole(request, actor); + } + + /** + * 编辑自定义角色,需要角色管理权限;内置角色会被服务层拒绝。 + */ + @PutMapping(value = "/{roleId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminRoleDetailResult updateRole( + @PathVariable String roleId, + @RequestBody(required = false) AdminRoleUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.updateRole(roleId, request, actor); + } + + /** + * 覆盖自定义角色权限,需要角色管理权限。 + */ + @PutMapping(value = "/{roleId}/permissions", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminRoleDetailResult assignPermissions( + @PathVariable String roleId, + @RequestBody(required = false) AdminRolePermissionAssignmentRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_ROLE_MANAGE.name()); + return accessManagementService.assignPermissions(roleId, request, actor); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java index 08126f9..2135a42 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java @@ -10,7 +10,9 @@ import cn.nianxx.thhotel.platform.access.mapper.PlatformPermissionMapper; import cn.nianxx.thhotel.platform.access.mapper.PlatformRoleMapper; import cn.nianxx.thhotel.platform.access.mapper.PlatformRolePermissionMapper; import cn.nianxx.thhotel.platform.access.mapper.PlatformUserRoleMapper; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.Collections; @@ -18,6 +20,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Repository; /** @@ -49,6 +52,14 @@ public class MybatisPlatformAccessRepository implements PlatformAccessRepository .last("LIMIT 1"))); } + @Override + public Optional findRoleById(Long roleId) { + if (roleId == null) { + return Optional.empty(); + } + return Optional.ofNullable(roleMapper.selectById(roleId)); + } + @Override public Optional findPermissionByCode(String permissionCode) { return Optional.ofNullable(permissionMapper.selectOne(Wrappers.lambdaQuery() @@ -56,6 +67,96 @@ public class MybatisPlatformAccessRepository implements PlatformAccessRepository .last("LIMIT 1"))); } + @Override + public Optional findPermissionById(Long permissionId) { + if (permissionId == null) { + return Optional.empty(); + } + return Optional.ofNullable(permissionMapper.selectById(permissionId)); + } + + @Override + public PlatformPageSnapshot queryRoles(String keyword, String roleStatus, int pageNum, int pageSize) { + Page page = roleMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(roleStatus != null && !roleStatus.isBlank(), PlatformRoleEntity::getRoleStatus, roleStatus) + .and(keyword != null && !keyword.isBlank(), wrapper -> wrapper + .like(PlatformRoleEntity::getRoleCode, keyword) + .or() + .like(PlatformRoleEntity::getRoleName, keyword)) + .orderByDesc(PlatformRoleEntity::getSystemBuiltin) + .orderByAsc(PlatformRoleEntity::getRoleCode)); + return new PlatformPageSnapshot<>(page.getRecords(), page.getTotal(), pageNum, pageSize); + } + + @Override + public List listAllRoles() { + return roleMapper.selectList(Wrappers.lambdaQuery() + .orderByDesc(PlatformRoleEntity::getSystemBuiltin) + .orderByAsc(PlatformRoleEntity::getRoleCode)); + } + + @Override + public List listRolesByIds(List roleIds) { + if (roleIds == null || roleIds.isEmpty()) { + return Collections.emptyList(); + } + return roleMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformRoleEntity::getId, roleIds)); + } + + @Override + public List listAllPermissions() { + return permissionMapper.selectList(Wrappers.lambdaQuery() + .orderByAsc(PlatformPermissionEntity::getPermissionGroup) + .orderByAsc(PlatformPermissionEntity::getPermissionCode)); + } + + @Override + public List listUserRolesByUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return userRoleMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformUserRoleEntity::getUserId, userIds)); + } + + @Override + public List listRolePermissionsByRoleIds(List roleIds) { + if (roleIds == null || roleIds.isEmpty()) { + return Collections.emptyList(); + } + return rolePermissionMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformRolePermissionEntity::getRoleId, roleIds)); + } + + @Override + public List listRolePermissionsByRoleId(Long roleId) { + if (roleId == null) { + return Collections.emptyList(); + } + return rolePermissionMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId)); + } + + @Override + public long countUsersByRoleId(Long roleId) { + if (roleId == null) { + return 0L; + } + return userRoleMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformUserRoleEntity::getRoleId, roleId)); + } + + @Override + public long countPermissionsByRoleId(Long roleId) { + if (roleId == null) { + return 0L; + } + return rolePermissionMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId)); + } + @Override public void insertRole(PlatformRoleEntity role) { roleMapper.insert(role); @@ -88,7 +189,47 @@ public class MybatisPlatformAccessRepository implements PlatformAccessRepository relation.setRoleId(roleId); relation.setPermissionId(permissionId); relation.setCreatedAt(nowUtc()); - rolePermissionMapper.insert(relation); + try { + rolePermissionMapper.insert(relation); + } catch (DuplicateKeyException ignored) { + // 多实例同时启动时唯一索引已经保证关系存在,重复插入可安全忽略。 + } + } + + @Override + public void syncRolePermissions(Long roleId, List permissionIds) { + Set expectedPermissionIds = new LinkedHashSet<>(permissionIds == null + ? Collections.emptyList() + : permissionIds); + expectedPermissionIds.forEach(permissionId -> ensureRolePermission(roleId, permissionId)); + if (expectedPermissionIds.isEmpty()) { + rolePermissionMapper.delete(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId)); + return; + } + rolePermissionMapper.delete(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId) + .notIn(PlatformRolePermissionEntity::getPermissionId, expectedPermissionIds)); + } + + @Override + public void replaceUserRoles(Long userId, List roleIds) { + userRoleMapper.delete(Wrappers.lambdaQuery() + .eq(PlatformUserRoleEntity::getUserId, userId)); + if (roleIds == null || roleIds.isEmpty()) { + return; + } + new LinkedHashSet<>(roleIds).forEach(roleId -> ensureUserRole(userId, roleId)); + } + + @Override + public void replaceRolePermissions(Long roleId, List permissionIds) { + rolePermissionMapper.delete(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId)); + if (permissionIds == null || permissionIds.isEmpty()) { + return; + } + new LinkedHashSet<>(permissionIds).forEach(permissionId -> ensureRolePermission(roleId, permissionId)); } @Override @@ -103,7 +244,11 @@ public class MybatisPlatformAccessRepository implements PlatformAccessRepository relation.setUserId(userId); relation.setRoleId(roleId); relation.setCreatedAt(nowUtc()); - userRoleMapper.insert(relation); + try { + userRoleMapper.insert(relation); + } catch (DuplicateKeyException ignored) { + // 多实例同时启动时唯一索引已经保证关系存在,重复插入可安全忽略。 + } } @Override diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java index ac76f24..3d368f9 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java @@ -1,5 +1,8 @@ package cn.nianxx.thhotel.platform.access.repository; +import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; import java.util.List; @@ -15,11 +18,66 @@ public interface PlatformAccessRepository { */ Optional findRoleByCode(String roleCode); + /** + * 按角色内部 ID 查询角色。 + */ + Optional findRoleById(Long roleId); + /** * 按权限代码查询权限。 */ Optional findPermissionByCode(String permissionCode); + /** + * 按权限内部 ID 查询权限。 + */ + Optional findPermissionById(Long permissionId); + + /** + * 管理后台分页查询角色。 + */ + PlatformPageSnapshot queryRoles(String keyword, String roleStatus, int pageNum, int pageSize); + + /** + * 查询全部角色。 + */ + List listAllRoles(); + + /** + * 查询指定角色 ID 列表对应角色。 + */ + List listRolesByIds(List roleIds); + + /** + * 查询全部权限。 + */ + List listAllPermissions(); + + /** + * 查询指定用户 ID 列表对应用户角色关系。 + */ + List listUserRolesByUserIds(List userIds); + + /** + * 查询指定角色 ID 列表对应角色权限关系。 + */ + List listRolePermissionsByRoleIds(List roleIds); + + /** + * 查询单个角色的权限关系。 + */ + List listRolePermissionsByRoleId(Long roleId); + + /** + * 统计角色关联用户数量。 + */ + long countUsersByRoleId(Long roleId); + + /** + * 统计角色关联权限数量。 + */ + long countPermissionsByRoleId(Long roleId); + /** * 新增角色。 */ @@ -45,11 +103,26 @@ public interface PlatformAccessRepository { */ void ensureRolePermission(Long roleId, Long permissionId); + /** + * 同步内置角色权限矩阵,确保预期权限存在并删除不再属于矩阵的旧关系。 + */ + void syncRolePermissions(Long roleId, List permissionIds); + /** * 确保用户角色关系存在。 */ void ensureUserRole(Long userId, Long roleId); + /** + * 覆盖指定用户的角色关系。 + */ + void replaceUserRoles(Long userId, List roleIds); + + /** + * 覆盖指定角色的权限关系。 + */ + void replaceRolePermissions(Long roleId, List permissionIds); + /** * 查询用户全部启用权限码。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AdminAccessManagementService.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AdminAccessManagementService.java new file mode 100644 index 0000000..a8687cc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AdminAccessManagementService.java @@ -0,0 +1,54 @@ +package cn.nianxx.thhotel.platform.access.service; + +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleCreateRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRolePermissionAssignmentRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleUpdateRequest; +import cn.nianxx.thhotel.platform.access.common.result.AdminPermissionResult; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleDetailResult; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleListItemResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import java.util.List; + +/** + * 管理后台角色权限管理服务。第一版提供角色和权限查询。 + */ +public interface AdminAccessManagementService { + + /** + * 分页查询角色列表。 + */ + PlatformPageResult queryRoles( + String keyword, + String roleStatus, + Integer pageNum, + Integer pageSize); + + /** + * 查询角色详情。 + */ + AdminRoleDetailResult getRole(String roleId); + + /** + * 查询全部权限码。 + */ + List listPermissions(); + + /** + * 新增自定义角色。 + */ + AdminRoleDetailResult createRole(AdminRoleCreateRequest request, AuthenticatedUserContext actor); + + /** + * 编辑自定义角色基础信息。 + */ + AdminRoleDetailResult updateRole(String roleId, AdminRoleUpdateRequest request, AuthenticatedUserContext actor); + + /** + * 覆盖自定义角色权限关系。 + */ + AdminRoleDetailResult assignPermissions( + String roleId, + AdminRolePermissionAssignmentRequest request, + AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AdminAccessManagementServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AdminAccessManagementServiceImpl.java new file mode 100644 index 0000000..8b02c60 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AdminAccessManagementServiceImpl.java @@ -0,0 +1,360 @@ +package cn.nianxx.thhotel.platform.access.service.impl; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleStatus; +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleCreateRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRolePermissionAssignmentRequest; +import cn.nianxx.thhotel.platform.access.common.request.AdminRoleUpdateRequest; +import cn.nianxx.thhotel.platform.access.common.result.AdminPermissionResult; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleDetailResult; +import cn.nianxx.thhotel.platform.access.common.result.AdminRoleListItemResult; +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.access.service.AdminAccessManagementService; +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 管理后台角色权限管理服务实现。自定义角色可维护,内置角色只读。 + */ +@Service +public class AdminAccessManagementServiceImpl implements AdminAccessManagementService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final PlatformAccessRepository accessRepository; + private final PlatformAdminAuditLogService auditLogService; + private final ObjectMapper objectMapper; + + /** + * 注入角色权限仓储。 + */ + public AdminAccessManagementServiceImpl( + PlatformAccessRepository accessRepository, + PlatformAdminAuditLogService auditLogService, + ObjectMapper objectMapper) { + this.accessRepository = accessRepository; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; + } + + /** + * 分页查询角色列表,并补齐权限数量和用户数量。 + */ + @Override + public PlatformPageResult queryRoles( + String keyword, + String roleStatus, + Integer pageNum, + Integer pageSize) { + int normalizedPageNum = normalizePageNum(pageNum); + int normalizedPageSize = normalizePageSize(pageSize); + PlatformPageSnapshot page = accessRepository.queryRoles( + trimToNull(keyword), + trimToNull(roleStatus), + normalizedPageNum, + normalizedPageSize); + List items = page.items().stream() + .map(role -> new AdminRoleListItemResult( + stringId(role.getId()), + role.getRoleCode(), + role.getRoleName(), + role.getRoleStatus(), + role.getSystemBuiltin(), + accessRepository.countPermissionsByRoleId(role.getId()), + accessRepository.countUsersByRoleId(role.getId()))) + .toList(); + return new PlatformPageResult<>( + items, + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 查询角色详情和角色权限列表。 + */ + @Override + public AdminRoleDetailResult getRole(String roleId) { + Long id = parseId(roleId, "角色 ID 不合法。"); + PlatformRoleEntity role = accessRepository.findRoleById(id) + .orElseThrow(() -> notFound("角色不存在。")); + Map permissionById = accessRepository.listAllPermissions().stream() + .collect(Collectors.toMap(PlatformPermissionEntity::getId, permission -> permission)); + List permissions = accessRepository.listRolePermissionsByRoleId(id).stream() + .map(PlatformRolePermissionEntity::getPermissionId) + .map(permissionById::get) + .filter(permission -> permission != null) + .map(this::toPermissionResult) + .toList(); + return new AdminRoleDetailResult( + stringId(role.getId()), + role.getRoleCode(), + role.getRoleName(), + role.getRoleStatus(), + role.getSystemBuiltin(), + UtcTimeFormatter.toUtcOffsetDateTime(role.getCreatedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(role.getUpdatedAt()), + permissions, + accessRepository.countUsersByRoleId(role.getId())); + } + + /** + * 查询全部权限码,供角色分配和菜单权限下拉使用。 + */ + @Override + public List listPermissions() { + return accessRepository.listAllPermissions().stream() + .map(this::toPermissionResult) + .toList(); + } + + /** + * 新增自定义角色,角色代码一旦创建不提供修改入口。 + */ + @Override + @Transactional + public AdminRoleDetailResult createRole(AdminRoleCreateRequest request, AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("角色创建请求不能为空。"); + } + String roleCode = requireRoleCode(request.roleCode()); + if (accessRepository.findRoleByCode(roleCode).isPresent()) { + throw conflict("角色代码已存在。"); + } + String roleName = requireText(request.roleName(), "角色名称不能为空。"); + String roleStatus = normalizeStatus(request.roleStatus(), PlatformRoleStatus.ACTIVE.name()); + validateRoleStatus(roleStatus); + PlatformRoleEntity role = new PlatformRoleEntity(); + role.setRoleCode(roleCode); + role.setRoleName(roleName); + role.setRoleStatus(roleStatus); + role.setSystemBuiltin(false); + role.setCreatedAt(nowUtc()); + role.setUpdatedAt(nowUtc()); + accessRepository.insertRole(role); + AdminRoleDetailResult after = getRole(stringId(role.getId())); + audit(actor, "PLATFORM_ROLE", stringId(role.getId()), "CREATE_ROLE", null, after); + return after; + } + + /** + * 编辑自定义角色基础信息;内置角色由启动同步矩阵管理,不允许页面修改。 + */ + @Override + @Transactional + public AdminRoleDetailResult updateRole( + String roleId, + AdminRoleUpdateRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("角色更新请求不能为空。"); + } + Long id = parseId(roleId, "角色 ID 不合法。"); + PlatformRoleEntity role = accessRepository.findRoleById(id) + .orElseThrow(() -> notFound("角色不存在。")); + ensureCustomRole(role); + AdminRoleDetailResult before = getRole(roleId); + String roleName = requireText(request.roleName(), "角色名称不能为空。"); + String roleStatus = normalizeStatus(request.roleStatus(), role.getRoleStatus()); + validateRoleStatus(roleStatus); + role.setRoleName(roleName); + role.setRoleStatus(roleStatus); + role.setUpdatedAt(nowUtc()); + accessRepository.updateRole(role); + AdminRoleDetailResult after = getRole(roleId); + audit(actor, "PLATFORM_ROLE", roleId, "UPDATE_ROLE", before, after); + return after; + } + + /** + * 覆盖自定义角色权限。内置角色权限继续由代码矩阵启动同步维护。 + */ + @Override + @Transactional + public AdminRoleDetailResult assignPermissions( + String roleId, + AdminRolePermissionAssignmentRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("角色权限分配请求不能为空。"); + } + Long id = parseId(roleId, "角色 ID 不合法。"); + PlatformRoleEntity role = accessRepository.findRoleById(id) + .orElseThrow(() -> notFound("角色不存在。")); + ensureCustomRole(role); + AdminRoleDetailResult before = getRole(roleId); + List permissionIds = parseIdList(request.permissionIds(), "权限 ID 不合法。"); + validatePermissionsExist(permissionIds); + accessRepository.replaceRolePermissions(id, permissionIds); + AdminRoleDetailResult after = getRole(roleId); + audit(actor, "PLATFORM_ROLE", roleId, "ASSIGN_ROLE_PERMISSIONS", before, after); + return after; + } + + private AdminPermissionResult toPermissionResult(PlatformPermissionEntity permission) { + return new AdminPermissionResult( + stringId(permission.getId()), + permission.getPermissionCode(), + permission.getPermissionName(), + permission.getPermissionGroup(), + permission.getPermissionStatus(), + permission.getSystemBuiltin()); + } + + private int normalizePageNum(Integer pageNum) { + return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private Long parseId(String id, String message) { + try { + return Long.valueOf(id); + } catch (NumberFormatException exception) { + throw new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + } + + private AdminOperationException notFound(String message) { + return new AdminOperationException(HttpStatus.NOT_FOUND, "ADMIN_TARGET_NOT_FOUND", message); + } + + private AdminOperationException invalidRequest(String message) { + return new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + + private AdminOperationException conflict(String message) { + return new AdminOperationException(HttpStatus.CONFLICT, "ADMIN_CONFLICT", message); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } + + private String requireText(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + return normalized; + } + + private String requireRoleCode(String value) { + String normalized = requireText(value, "角色代码不能为空。").toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z0-9_]{3,128}")) { + throw invalidRequest("角色代码只能包含大写字母、数字和下划线。"); + } + return normalized; + } + + private String normalizeStatus(String value, String defaultStatus) { + String normalized = trimToNull(value); + return normalized == null ? defaultStatus : normalized.toUpperCase(Locale.ROOT); + } + + private void validateRoleStatus(String status) { + if (!PlatformRoleStatus.ACTIVE.name().equals(status) && !PlatformRoleStatus.DISABLED.name().equals(status)) { + throw invalidRequest("角色状态不合法。"); + } + } + + private void ensureCustomRole(PlatformRoleEntity role) { + if (Boolean.TRUE.equals(role.getSystemBuiltin())) { + throw conflict("内置角色不允许通过管理后台修改。"); + } + } + + private List parseIdList(List ids, String message) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (String id : ids) { + result.add(parseId(id, message)); + } + return new ArrayList<>(new LinkedHashSet<>(result)); + } + + private void validatePermissionsExist(List permissionIds) { + if (permissionIds == null || permissionIds.isEmpty()) { + return; + } + Set existingPermissionIds = accessRepository.listAllPermissions().stream() + .map(PlatformPermissionEntity::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (!existingPermissionIds.containsAll(permissionIds)) { + throw invalidRequest("存在不存在的权限 ID。"); + } + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private void audit( + AuthenticatedUserContext actor, + String targetType, + String targetId, + String action, + Object before, + Object after) { + auditLogService.record(new PlatformAdminAuditLogDraft( + actor, + targetType, + targetId, + action, + toJson(before), + toJson(after))); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new AdminOperationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_AUDIT_SERIALIZE_FAILED", + "系统管理审计序列化失败。"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/dto/PlatformAdminAuditLogDraft.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/dto/PlatformAdminAuditLogDraft.java new file mode 100644 index 0000000..c41143f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/dto/PlatformAdminAuditLogDraft.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.audit.common.dto; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 平台管理审计写入草稿。调用方必须提前清理密码、token 和 secret。 + */ +public record PlatformAdminAuditLogDraft( + /** 操作用户上下文。 */ + AuthenticatedUserContext actor, + /** 操作对象类型。 */ + String targetType, + /** 操作对象稳定 ID。 */ + String targetId, + /** 操作类型。 */ + String action, + /** 变更前摘要 JSON。 */ + String beforeSnapshotJson, + /** 变更后摘要 JSON。 */ + String afterSnapshotJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/result/AdminAuditLogResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/result/AdminAuditLogResult.java new file mode 100644 index 0000000..6e9b34f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/common/result/AdminAuditLogResult.java @@ -0,0 +1,39 @@ +package cn.nianxx.thhotel.platform.audit.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 管理后台审计记录结果。 + */ +public record AdminAuditLogResult( + /** 管理审计内部 ID 字符串。 */ + String id, + /** 操作用户内部 ID 字符串。 */ + @JsonProperty("actor_user_id") + String actorUserId, + /** 操作用户登录名摘要。 */ + @JsonProperty("actor_username") + String actorUsername, + /** 操作用户展示名摘要。 */ + @JsonProperty("actor_display_name") + String actorDisplayName, + /** 操作对象类型。 */ + @JsonProperty("target_type") + String targetType, + /** 操作对象稳定 ID。 */ + @JsonProperty("target_id") + String targetId, + /** 操作类型。 */ + String action, + /** 变更前摘要 JSON。 */ + @JsonProperty("before_snapshot_json") + String beforeSnapshotJson, + /** 变更后摘要 JSON。 */ + @JsonProperty("after_snapshot_json") + String afterSnapshotJson, + /** 操作发生 UTC 时间。 */ + @JsonProperty("occurred_at") + OffsetDateTime occurredAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/control/AdminAuditLogController.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/control/AdminAuditLogController.java new file mode 100644 index 0000000..f3068ff --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/control/AdminAuditLogController.java @@ -0,0 +1,47 @@ +package cn.nianxx.thhotel.platform.audit.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.audit.common.result.AdminAuditLogResult; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台审计 Controller。提供管理写操作审计列表。 + */ +@RestController +@RequestMapping("/api/admin/audits") +public class AdminAuditLogController { + + private final AdminAuthorizationService authorizationService; + private final PlatformAdminAuditLogService auditLogService; + + /** + * 注入管理鉴权和审计服务。 + */ + public AdminAuditLogController( + AdminAuthorizationService authorizationService, + PlatformAdminAuditLogService auditLogService) { + this.authorizationService = authorizationService; + this.auditLogService = auditLogService; + } + + /** + * 分页查询管理审计,需要系统管理入口权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listAudits( + @RequestParam(name = "target_type", required = false) String targetType, + @RequestParam(name = "target_id", required = false) String targetId, + @RequestParam(required = false) String action, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_ADMIN_CONSOLE_ACCESS.name()); + return auditLogService.query(targetType, targetId, action, pageNum, pageSize); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/domain/PlatformAdminAuditLogEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/domain/PlatformAdminAuditLogEntity.java new file mode 100644 index 0000000..231af38 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/domain/PlatformAdminAuditLogEntity.java @@ -0,0 +1,56 @@ +package cn.nianxx.thhotel.platform.audit.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台管理审计实体。只记录管理写操作摘要,不保存密码、token 或 secret。 + */ +@TableName("platform_admin_audit_log") +public class PlatformAdminAuditLogEntity { + + /** 管理审计内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 操作用户内部 ID。 */ + private Long actorUserId; + /** 操作用户登录名摘要。 */ + private String actorUsername; + /** 操作用户展示名摘要。 */ + private String actorDisplayName; + /** 操作对象类型:USER、ROLE、MENU、HOTEL 等。 */ + private String targetType; + /** 操作对象稳定 ID。 */ + private String targetId; + /** 操作类型。 */ + private String action; + /** 变更前摘要 JSON。 */ + private String beforeSnapshotJson; + /** 变更后摘要 JSON。 */ + private String afterSnapshotJson; + /** 操作发生 UTC 时间。 */ + private LocalDateTime occurredAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getActorUserId() { return actorUserId; } + public void setActorUserId(Long actorUserId) { this.actorUserId = actorUserId; } + public String getActorUsername() { return actorUsername; } + public void setActorUsername(String actorUsername) { this.actorUsername = actorUsername; } + public String getActorDisplayName() { return actorDisplayName; } + public void setActorDisplayName(String actorDisplayName) { this.actorDisplayName = actorDisplayName; } + public String getTargetType() { return targetType; } + public void setTargetType(String targetType) { this.targetType = targetType; } + public String getTargetId() { return targetId; } + public void setTargetId(String targetId) { this.targetId = targetId; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public String getBeforeSnapshotJson() { return beforeSnapshotJson; } + public void setBeforeSnapshotJson(String beforeSnapshotJson) { this.beforeSnapshotJson = beforeSnapshotJson; } + public String getAfterSnapshotJson() { return afterSnapshotJson; } + public void setAfterSnapshotJson(String afterSnapshotJson) { this.afterSnapshotJson = afterSnapshotJson; } + public LocalDateTime getOccurredAt() { return occurredAt; } + public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt = occurredAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/mapper/PlatformAdminAuditLogMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/mapper/PlatformAdminAuditLogMapper.java new file mode 100644 index 0000000..30663d8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/mapper/PlatformAdminAuditLogMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.audit.mapper; + +import cn.nianxx.thhotel.platform.audit.domain.PlatformAdminAuditLogEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台管理审计 Mapper。MyBatis-Plus 自带基础写入能力。 + */ +public interface PlatformAdminAuditLogMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/MybatisPlatformAdminAuditLogRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/MybatisPlatformAdminAuditLogRepository.java new file mode 100644 index 0000000..342e504 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/MybatisPlatformAdminAuditLogRepository.java @@ -0,0 +1,49 @@ +package cn.nianxx.thhotel.platform.audit.repository; + +import cn.nianxx.thhotel.platform.audit.domain.PlatformAdminAuditLogEntity; +import cn.nianxx.thhotel.platform.audit.mapper.PlatformAdminAuditLogMapper; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的平台管理审计仓储实现。 + */ +@Repository +public class MybatisPlatformAdminAuditLogRepository implements PlatformAdminAuditLogRepository { + + private final PlatformAdminAuditLogMapper auditLogMapper; + + public MybatisPlatformAdminAuditLogRepository(PlatformAdminAuditLogMapper auditLogMapper) { + this.auditLogMapper = auditLogMapper; + } + + /** + * 新增管理审计记录。 + */ + @Override + public void insert(PlatformAdminAuditLogEntity entity) { + auditLogMapper.insert(entity); + } + + /** + * 分页查询管理审计记录。 + */ + @Override + public PlatformPageSnapshot query( + String targetType, + String targetId, + String action, + int pageNum, + int pageSize) { + Page page = auditLogMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(targetType != null && !targetType.isBlank(), PlatformAdminAuditLogEntity::getTargetType, targetType) + .eq(targetId != null && !targetId.isBlank(), PlatformAdminAuditLogEntity::getTargetId, targetId) + .eq(action != null && !action.isBlank(), PlatformAdminAuditLogEntity::getAction, action) + .orderByDesc(PlatformAdminAuditLogEntity::getOccurredAt) + .orderByDesc(PlatformAdminAuditLogEntity::getId)); + return new PlatformPageSnapshot<>(page.getRecords(), page.getTotal(), pageNum, pageSize); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/PlatformAdminAuditLogRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/PlatformAdminAuditLogRepository.java new file mode 100644 index 0000000..af3beae --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/repository/PlatformAdminAuditLogRepository.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.platform.audit.repository; + +import cn.nianxx.thhotel.platform.audit.domain.PlatformAdminAuditLogEntity; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; + +/** + * 平台管理审计仓储。隔离审计服务对 MyBatis Mapper 的直接依赖。 + */ +public interface PlatformAdminAuditLogRepository { + + /** + * 新增一条管理审计记录。 + */ + void insert(PlatformAdminAuditLogEntity entity); + + /** + * 分页查询管理审计记录。 + */ + PlatformPageSnapshot query( + String targetType, + String targetId, + String action, + int pageNum, + int pageSize); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/PlatformAdminAuditLogService.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/PlatformAdminAuditLogService.java new file mode 100644 index 0000000..ef4bb2a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/PlatformAdminAuditLogService.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.audit.service; + +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.common.result.AdminAuditLogResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; + +/** + * 平台管理审计服务。系统管理写操作通过该服务记录变更摘要。 + */ +public interface PlatformAdminAuditLogService { + + /** + * 记录一条平台管理写操作审计。 + */ + void record(PlatformAdminAuditLogDraft draft); + + /** + * 分页查询平台管理审计。 + */ + PlatformPageResult query( + String targetType, + String targetId, + String action, + Integer pageNum, + Integer pageSize); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/impl/PlatformAdminAuditLogServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/impl/PlatformAdminAuditLogServiceImpl.java new file mode 100644 index 0000000..b5e68ba --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/audit/service/impl/PlatformAdminAuditLogServiceImpl.java @@ -0,0 +1,114 @@ +package cn.nianxx.thhotel.platform.audit.service.impl; + +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.common.result.AdminAuditLogResult; +import cn.nianxx.thhotel.platform.audit.domain.PlatformAdminAuditLogEntity; +import cn.nianxx.thhotel.platform.audit.repository.PlatformAdminAuditLogRepository; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.springframework.stereotype.Service; + +/** + * 平台管理审计服务实现。负责补齐 actor 和 UTC 时间。 + */ +@Service +public class PlatformAdminAuditLogServiceImpl implements PlatformAdminAuditLogService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final PlatformAdminAuditLogRepository auditLogRepository; + + /** + * 注入管理审计仓储。 + */ + public PlatformAdminAuditLogServiceImpl(PlatformAdminAuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + /** + * 记录平台管理写操作审计;空草稿直接忽略,避免管理主流程被空审计破坏。 + */ + @Override + public void record(PlatformAdminAuditLogDraft draft) { + if (draft == null || draft.actor() == null) { + return; + } + PlatformAdminAuditLogEntity entity = new PlatformAdminAuditLogEntity(); + entity.setActorUserId(draft.actor().userId()); + entity.setActorUsername(draft.actor().username()); + entity.setActorDisplayName(draft.actor().displayName()); + entity.setTargetType(draft.targetType()); + entity.setTargetId(draft.targetId()); + entity.setAction(draft.action()); + entity.setBeforeSnapshotJson(draft.beforeSnapshotJson()); + entity.setAfterSnapshotJson(draft.afterSnapshotJson()); + entity.setOccurredAt(LocalDateTime.now(ZoneOffset.UTC)); + auditLogRepository.insert(entity); + } + + /** + * 分页查询平台管理审计,供系统管理后台展示写操作历史。 + */ + @Override + public PlatformPageResult query( + String targetType, + String targetId, + String action, + Integer pageNum, + Integer pageSize) { + int normalizedPageNum = normalizePageNum(pageNum); + int normalizedPageSize = normalizePageSize(pageSize); + PlatformPageSnapshot page = auditLogRepository.query( + trimToNull(targetType), + trimToNull(targetId), + trimToNull(action), + normalizedPageNum, + normalizedPageSize); + return new PlatformPageResult<>( + page.items().stream().map(this::toResult).toList(), + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + private AdminAuditLogResult toResult(PlatformAdminAuditLogEntity entity) { + return new AdminAuditLogResult( + stringId(entity.getId()), + stringId(entity.getActorUserId()), + entity.getActorUsername(), + entity.getActorDisplayName(), + entity.getTargetType(), + entity.getTargetId(), + entity.getAction(), + entity.getBeforeSnapshotJson(), + entity.getAfterSnapshotJson(), + UtcTimeFormatter.toUtcOffsetDateTime(entity.getOccurredAt())); + } + + private int normalizePageNum(Integer pageNum) { + return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/dto/PlatformPageSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/dto/PlatformPageSnapshot.java new file mode 100644 index 0000000..7dc9b0d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/dto/PlatformPageSnapshot.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.common.dto; + +import java.util.List; + +/** + * 平台仓储分页快照。Repository 使用 Java 字段名,Controller 响应再转换为 snake_case。 + */ +public record PlatformPageSnapshot( + /** 当前页记录。 */ + List items, + /** 符合条件的总记录数。 */ + long total, + /** 当前页码,从 1 开始。 */ + int pageNum, + /** 每页数量。 */ + int pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/exception/AdminOperationException.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/exception/AdminOperationException.java new file mode 100644 index 0000000..646d377 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/exception/AdminOperationException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.common.exception; + +import org.springframework.http.HttpStatus; + +/** + * 系统管理业务受控异常。用于对象不存在、状态冲突和参数非法等管理后台错误。 + */ +public class AdminOperationException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public AdminOperationException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformErrorResponse.java new file mode 100644 index 0000000..94ea96d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformErrorResponse.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 平台通用错误响应。错误内容不得包含 token、secret 或密码等敏感信息。 + */ +public record PlatformErrorResponse( + /** 稳定错误码。 */ + @JsonProperty("error_code") + String errorCode, + /** 面向用户的错误提示。 */ + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPageResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPageResult.java new file mode 100644 index 0000000..89345da --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPageResult.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.common.result; + +import java.util.List; + +/** + * 平台统一分页响应。items 为当前页数据,page 为分页摘要。 + */ +public record PlatformPageResult( + /** 当前页记录。 */ + List items, + /** 分页摘要。 */ + PlatformPaginationResult page +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPaginationResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPaginationResult.java new file mode 100644 index 0000000..568b8df --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/result/PlatformPaginationResult.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 平台统一分页摘要。分页参数与 Reservation 前端接口保持一致。 + */ +public record PlatformPaginationResult( + /** 当前页码,从 1 开始。 */ + @JsonProperty("page_num") + int pageNum, + /** 每页数量。 */ + @JsonProperty("page_size") + int pageSize, + /** 符合条件的总记录数。 */ + long total +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelCreateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelCreateRequest.java new file mode 100644 index 0000000..1cab6e5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelCreateRequest.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.hotel.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台新增酒店请求。单酒店阶段新增后默认 DISABLED。 + */ +public record AdminHotelCreateRequest( + /** 酒店业务 ID,全局唯一,新增后不允许修改。 */ + @JsonProperty("hotel_id") + String hotelId, + /** 酒店展示名称。 */ + @JsonProperty("hotel_name") + String hotelName, + /** 酒店本地时区。 */ + @JsonProperty("time_zone") + String timeZone, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelStatusUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelStatusUpdateRequest.java new file mode 100644 index 0000000..1cda473 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.platform.hotel.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台酒店状态更新请求。 + */ +public record AdminHotelStatusUpdateRequest( + /** 酒店状态:ACTIVE、DISABLED。 */ + @JsonProperty("hotel_status") + String hotelStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelUpdateRequest.java new file mode 100644 index 0000000..721ee51 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/request/AdminHotelUpdateRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.hotel.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台编辑酒店请求。hotelId 新增后不允许修改。 + */ +public record AdminHotelUpdateRequest( + /** 酒店展示名称。 */ + @JsonProperty("hotel_name") + String hotelName, + /** 酒店本地时区。 */ + @JsonProperty("time_zone") + String timeZone, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/result/AdminHotelResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/result/AdminHotelResult.java new file mode 100644 index 0000000..2131ff9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/result/AdminHotelResult.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.platform.hotel.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 管理后台酒店结果。 + */ +public record AdminHotelResult( + /** 酒店内部 ID 字符串。 */ + String id, + /** 酒店业务 ID。 */ + @JsonProperty("hotel_id") + String hotelId, + /** 酒店展示名称。 */ + @JsonProperty("hotel_name") + String hotelName, + /** 酒店状态。 */ + @JsonProperty("hotel_status") + String hotelStatus, + /** 酒店本地时区。 */ + @JsonProperty("time_zone") + String timeZone, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder, + /** 授权用户数量。 */ + @JsonProperty("authorized_user_count") + long authorizedUserCount, + /** 创建 UTC 时间。 */ + @JsonProperty("created_at") + OffsetDateTime createdAt, + /** 更新 UTC 时间。 */ + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/control/AdminHotelController.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/control/AdminHotelController.java new file mode 100644 index 0000000..807c0dd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/control/AdminHotelController.java @@ -0,0 +1,94 @@ +package cn.nianxx.thhotel.platform.hotel.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelCreateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelStatusUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.result.AdminHotelResult; +import cn.nianxx.thhotel.platform.hotel.service.AdminHotelManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台酒店 Controller。提供酒店查询入口。 + */ +@RestController +@RequestMapping("/api/admin/hotels") +public class AdminHotelController { + + private final AdminAuthorizationService authorizationService; + private final AdminHotelManagementService hotelManagementService; + + /** + * 注入管理鉴权和酒店管理服务。 + */ + public AdminHotelController( + AdminAuthorizationService authorizationService, + AdminHotelManagementService hotelManagementService) { + this.authorizationService = authorizationService; + this.hotelManagementService = hotelManagementService; + } + + /** + * 分页查询酒店列表,需要酒店管理权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listHotels( + @RequestParam(required = false) String keyword, + @RequestParam(name = "hotel_status", required = false) String hotelStatus, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.HOTEL_MANAGE.name()); + return hotelManagementService.queryHotels(keyword, hotelStatus, pageNum, pageSize); + } + + /** + * 查询酒店详情,需要酒店管理权限。 + */ + @GetMapping(value = "/{hotelId}", produces = MediaType.APPLICATION_JSON_VALUE) + public AdminHotelResult getHotel(@PathVariable String hotelId) { + authorizationService.requirePermission(PlatformPermissionCode.HOTEL_MANAGE.name()); + return hotelManagementService.getHotel(hotelId); + } + + /** + * 新增酒店,需要酒店管理权限,第一版新增后默认 DISABLED。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminHotelResult createHotel(@RequestBody(required = false) AdminHotelCreateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission(PlatformPermissionCode.HOTEL_MANAGE.name()); + return hotelManagementService.createHotel(request, actor); + } + + /** + * 编辑酒店基础信息,需要酒店管理权限,hotelId 不允许修改。 + */ + @PutMapping(value = "/{hotelId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminHotelResult updateHotel( + @PathVariable String hotelId, + @RequestBody(required = false) AdminHotelUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission(PlatformPermissionCode.HOTEL_MANAGE.name()); + return hotelManagementService.updateHotel(hotelId, request, actor); + } + + /** + * 更新酒店状态,需要酒店管理权限,启用时校验单酒店 ACTIVE 约束。 + */ + @PutMapping(value = "/{hotelId}/status", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminHotelResult updateHotelStatus( + @PathVariable String hotelId, + @RequestBody(required = false) AdminHotelStatusUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission(PlatformPermissionCode.HOTEL_MANAGE.name()); + return hotelManagementService.updateHotelStatus(hotelId, request, actor); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java index a0e33cc..e56c1b8 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java @@ -1,13 +1,16 @@ package cn.nianxx.thhotel.platform.hotel.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; import cn.nianxx.thhotel.platform.hotel.mapper.PlatformHotelMapper; import cn.nianxx.thhotel.platform.hotel.mapper.PlatformUserHotelMapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.Collections; import java.util.List; import java.util.Optional; import org.springframework.dao.DuplicateKeyException; @@ -36,6 +39,14 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { .last("LIMIT 1"))); } + @Override + public Optional findHotelById(Long id) { + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(hotelMapper.selectById(id)); + } + @Override public List listActiveHotels() { return hotelMapper.selectList(Wrappers.lambdaQuery() @@ -44,6 +55,36 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { .orderByAsc(PlatformHotelEntity::getHotelId)); } + @Override + public List listAllHotels() { + return hotelMapper.selectList(Wrappers.lambdaQuery() + .orderByAsc(PlatformHotelEntity::getSortOrder) + .orderByAsc(PlatformHotelEntity::getHotelId)); + } + + @Override + public PlatformPageSnapshot queryHotels(String keyword, String hotelStatus, int pageNum, int pageSize) { + Page page = hotelMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(hotelStatus != null && !hotelStatus.isBlank(), PlatformHotelEntity::getHotelStatus, hotelStatus) + .and(keyword != null && !keyword.isBlank(), wrapper -> wrapper + .like(PlatformHotelEntity::getHotelId, keyword) + .or() + .like(PlatformHotelEntity::getHotelName, keyword)) + .orderByAsc(PlatformHotelEntity::getSortOrder) + .orderByAsc(PlatformHotelEntity::getHotelId)); + return new PlatformPageSnapshot<>(page.getRecords(), page.getTotal(), pageNum, pageSize); + } + + @Override + public List listHotelsByHotelIds(List hotelIds) { + if (hotelIds == null || hotelIds.isEmpty()) { + return Collections.emptyList(); + } + return hotelMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformHotelEntity::getHotelId, hotelIds)); + } + @Override public List listUserHotelIds(Long userId) { return userHotelMapper.selectList(Wrappers.lambdaQuery() @@ -55,6 +96,39 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { .toList(); } + @Override + public List listUserHotelRelationsByUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return userHotelMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformUserHotelEntity::getUserId, userIds) + .orderByDesc(PlatformUserHotelEntity::getDefaultHotel) + .orderByAsc(PlatformUserHotelEntity::getHotelId)); + } + + @Override + public long countAuthorizedUsersByHotelId(String hotelId) { + if (hotelId == null || hotelId.isBlank()) { + return 0L; + } + return userHotelMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getHotelId, hotelId)); + } + + @Override + public long countActiveHotels() { + return hotelMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformHotelEntity::getHotelStatus, PlatformHotelStatus.ACTIVE.name())); + } + + @Override + public long countActiveHotelsExcluding(String hotelId) { + return hotelMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformHotelEntity::getHotelStatus, PlatformHotelStatus.ACTIVE.name()) + .ne(hotelId != null && !hotelId.isBlank(), PlatformHotelEntity::getHotelId, hotelId)); + } + @Override public Optional findUserDefaultHotelId(Long userId) { return Optional.ofNullable(userHotelMapper.selectOne(Wrappers.lambdaQuery() @@ -105,6 +179,18 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { } } + @Override + public void replaceUserHotels(Long userId, List hotelIds, String defaultHotelId) { + userHotelMapper.delete(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getUserId, userId)); + if (hotelIds == null || hotelIds.isEmpty()) { + return; + } + for (String hotelId : hotelIds) { + ensureUserHotel(userId, hotelId, hotelId.equals(defaultHotelId)); + } + } + /** * 查询单个用户酒店授权关系,用于写入前检查和并发重复插入后的状态补齐。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java index 95e0cb8..6f4fe70 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java @@ -1,6 +1,8 @@ package cn.nianxx.thhotel.platform.hotel.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; import java.util.List; import java.util.Optional; @@ -14,16 +16,56 @@ public interface PlatformHotelRepository { */ Optional findHotelByHotelId(String hotelId); + /** + * 按酒店内部 ID 查询酒店。 + */ + Optional findHotelById(Long id); + /** * 查询全部启用酒店。 */ List listActiveHotels(); + /** + * 查询全部酒店。 + */ + List listAllHotels(); + + /** + * 管理后台分页查询酒店。 + */ + PlatformPageSnapshot queryHotels(String keyword, String hotelStatus, int pageNum, int pageSize); + + /** + * 根据酒店业务 ID 列表查询酒店。 + */ + List listHotelsByHotelIds(List hotelIds); + /** * 查询普通用户授权酒店 ID 列表。 */ List listUserHotelIds(Long userId); + /** + * 查询指定用户 ID 列表对应用户酒店授权关系。 + */ + List listUserHotelRelationsByUserIds(List userIds); + + /** + * 统计酒店授权用户数量。 + */ + long countAuthorizedUsersByHotelId(String hotelId); + + /** + * 统计启用酒店数量。 + */ + long countActiveHotels(); + + /** + * 统计排除指定酒店后的启用酒店数量。 + */ + long countActiveHotelsExcluding(String hotelId); + /** * 查询普通用户默认酒店 ID。 */ @@ -43,4 +85,9 @@ public interface PlatformHotelRepository { * 确保用户酒店授权关系存在。 */ void ensureUserHotel(Long userId, String hotelId, boolean defaultHotel); + + /** + * 覆盖普通用户酒店授权关系,并设置唯一默认酒店。 + */ + void replaceUserHotels(Long userId, List hotelIds, String defaultHotelId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/AdminHotelManagementService.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/AdminHotelManagementService.java new file mode 100644 index 0000000..e2f16cf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/AdminHotelManagementService.java @@ -0,0 +1,46 @@ +package cn.nianxx.thhotel.platform.hotel.service; + +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelCreateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelStatusUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.result.AdminHotelResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 管理后台酒店管理服务。第一版提供酒店查询。 + */ +public interface AdminHotelManagementService { + + /** + * 分页查询酒店列表。 + */ + PlatformPageResult queryHotels( + String keyword, + String hotelStatus, + Integer pageNum, + Integer pageSize); + + /** + * 查询酒店详情。 + */ + AdminHotelResult getHotel(String hotelId); + + /** + * 新增酒店,单酒店阶段默认 DISABLED。 + */ + AdminHotelResult createHotel(AdminHotelCreateRequest request, AuthenticatedUserContext actor); + + /** + * 编辑酒店基础信息,hotelId 不允许修改。 + */ + AdminHotelResult updateHotel(String hotelId, AdminHotelUpdateRequest request, AuthenticatedUserContext actor); + + /** + * 更新酒店状态,启用时校验单酒店 ACTIVE 约束。 + */ + AdminHotelResult updateHotelStatus( + String hotelId, + AdminHotelStatusUpdateRequest request, + AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/AdminHotelManagementServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/AdminHotelManagementServiceImpl.java new file mode 100644 index 0000000..e3b6db2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/AdminHotelManagementServiceImpl.java @@ -0,0 +1,296 @@ +package cn.nianxx.thhotel.platform.hotel.service.impl; + +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelCreateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelStatusUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.request.AdminHotelUpdateRequest; +import cn.nianxx.thhotel.platform.hotel.common.result.AdminHotelResult; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.hotel.service.AdminHotelManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Locale; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 管理后台酒店管理服务实现。单酒店阶段必须主动保护唯一 ACTIVE 酒店约束。 + */ +@Service +public class AdminHotelManagementServiceImpl implements AdminHotelManagementService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final PlatformHotelRepository hotelRepository; + private final PlatformAdminAuditLogService auditLogService; + private final ObjectMapper objectMapper; + + /** + * 注入酒店仓储。 + */ + public AdminHotelManagementServiceImpl( + PlatformHotelRepository hotelRepository, + PlatformAdminAuditLogService auditLogService, + ObjectMapper objectMapper) { + this.hotelRepository = hotelRepository; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; + } + + /** + * 分页查询酒店列表。 + */ + @Override + public PlatformPageResult queryHotels( + String keyword, + String hotelStatus, + Integer pageNum, + Integer pageSize) { + int normalizedPageNum = normalizePageNum(pageNum); + int normalizedPageSize = normalizePageSize(pageSize); + PlatformPageSnapshot page = hotelRepository.queryHotels( + trimToNull(keyword), + trimToNull(hotelStatus), + normalizedPageNum, + normalizedPageSize); + return new PlatformPageResult<>( + page.items().stream().map(this::toResult).toList(), + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 按酒店业务 ID 查询酒店详情。 + */ + @Override + public AdminHotelResult getHotel(String hotelId) { + return hotelRepository.findHotelByHotelId(hotelId) + .map(this::toResult) + .orElseThrow(() -> notFound("酒店不存在。")); + } + + /** + * 新增酒店。单酒店阶段新增后默认 DISABLED,避免触发唯一 ACTIVE 酒店约束。 + */ + @Override + @Transactional + public AdminHotelResult createHotel(AdminHotelCreateRequest request, AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("酒店创建请求不能为空。"); + } + String hotelId = requireHotelId(request.hotelId()); + if (hotelRepository.findHotelByHotelId(hotelId).isPresent()) { + throw conflict("酒店 ID 已存在。"); + } + PlatformHotelEntity hotel = new PlatformHotelEntity(); + hotel.setHotelId(hotelId); + hotel.setHotelName(requireText(request.hotelName(), "酒店名称不能为空。")); + hotel.setTimeZone(requireTimeZone(request.timeZone())); + hotel.setHotelStatus(PlatformHotelStatus.DISABLED.name()); + hotel.setSortOrder(request.sortOrder() == null ? 0 : request.sortOrder()); + hotel.setCreatedAt(nowUtc()); + hotel.setUpdatedAt(nowUtc()); + hotelRepository.insertHotel(hotel); + AdminHotelResult after = getHotel(hotel.getHotelId()); + audit(actor, "PLATFORM_HOTEL", hotel.getHotelId(), "CREATE_HOTEL", null, after); + return after; + } + + /** + * 编辑酒店基础信息。hotelId 是稳定业务键,创建后不允许修改。 + */ + @Override + @Transactional + public AdminHotelResult updateHotel( + String hotelId, + AdminHotelUpdateRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("酒店更新请求不能为空。"); + } + PlatformHotelEntity hotel = findHotelOrThrow(hotelId); + AdminHotelResult before = toResult(hotel); + hotel.setHotelName(requireText(request.hotelName(), "酒店名称不能为空。")); + hotel.setTimeZone(requireTimeZone(request.timeZone())); + hotel.setSortOrder(request.sortOrder() == null ? hotel.getSortOrder() : request.sortOrder()); + hotel.setUpdatedAt(nowUtc()); + hotelRepository.updateHotel(hotel); + AdminHotelResult after = getHotel(hotel.getHotelId()); + audit(actor, "PLATFORM_HOTEL", hotel.getHotelId(), "UPDATE_HOTEL", before, after); + return after; + } + + /** + * 更新酒店状态。启用时拒绝第二家 ACTIVE,禁用时拒绝最后一家 ACTIVE。 + */ + @Override + @Transactional + public AdminHotelResult updateHotelStatus( + String hotelId, + AdminHotelStatusUpdateRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("酒店状态更新请求不能为空。"); + } + PlatformHotelEntity hotel = findHotelOrThrow(hotelId); + AdminHotelResult before = toResult(hotel); + String status = normalizeStatus(request.hotelStatus(), hotel.getHotelStatus()); + validateHotelStatus(status); + if (PlatformHotelStatus.ACTIVE.name().equals(status) + && hotelRepository.countActiveHotelsExcluding(hotel.getHotelId()) > 0) { + throw conflict("单酒店阶段不允许启用第二家 ACTIVE 酒店。"); + } + if (PlatformHotelStatus.DISABLED.name().equals(status) + && PlatformHotelStatus.ACTIVE.name().equals(hotel.getHotelStatus()) + && hotelRepository.countActiveHotels() <= 1) { + throw conflict("不能禁用最后一家 ACTIVE 酒店。"); + } + hotel.setHotelStatus(status); + hotel.setUpdatedAt(nowUtc()); + hotelRepository.updateHotel(hotel); + AdminHotelResult after = getHotel(hotel.getHotelId()); + audit(actor, "PLATFORM_HOTEL", hotel.getHotelId(), "UPDATE_HOTEL_STATUS", before, after); + return after; + } + + private AdminHotelResult toResult(PlatformHotelEntity hotel) { + return new AdminHotelResult( + stringId(hotel.getId()), + hotel.getHotelId(), + hotel.getHotelName(), + hotel.getHotelStatus(), + hotel.getTimeZone(), + hotel.getSortOrder(), + hotelRepository.countAuthorizedUsersByHotelId(hotel.getHotelId()), + UtcTimeFormatter.toUtcOffsetDateTime(hotel.getCreatedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(hotel.getUpdatedAt())); + } + + private int normalizePageNum(Integer pageNum) { + return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private AdminOperationException notFound(String message) { + return new AdminOperationException(HttpStatus.NOT_FOUND, "ADMIN_TARGET_NOT_FOUND", message); + } + + private AdminOperationException invalidRequest(String message) { + return new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + + private AdminOperationException conflict(String message) { + return new AdminOperationException(HttpStatus.CONFLICT, "ADMIN_CONFLICT", message); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } + + private PlatformHotelEntity findHotelOrThrow(String hotelId) { + String normalizedHotelId = trimToNull(hotelId); + if (normalizedHotelId == null) { + throw invalidRequest("酒店 ID 不能为空。"); + } + return hotelRepository.findHotelByHotelId(normalizedHotelId) + .orElseThrow(() -> notFound("酒店不存在。")); + } + + private String requireText(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + return normalized; + } + + private String requireHotelId(String value) { + String normalized = requireText(value, "酒店 ID 不能为空。").toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z0-9_-]{3,64}")) { + throw invalidRequest("酒店 ID 只能包含大写字母、数字、下划线和中划线。"); + } + return normalized; + } + + private String requireTimeZone(String value) { + String normalized = requireText(value, "酒店时区不能为空。"); + try { + ZoneId.of(normalized); + return normalized; + } catch (RuntimeException exception) { + throw invalidRequest("酒店时区不合法。"); + } + } + + private String normalizeStatus(String value, String defaultStatus) { + String normalized = trimToNull(value); + return normalized == null ? defaultStatus : normalized.toUpperCase(Locale.ROOT); + } + + private void validateHotelStatus(String status) { + if (!PlatformHotelStatus.ACTIVE.name().equals(status) && !PlatformHotelStatus.DISABLED.name().equals(status)) { + throw invalidRequest("酒店状态不合法。"); + } + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private void audit( + AuthenticatedUserContext actor, + String targetType, + String targetId, + String action, + Object before, + Object after) { + auditLogService.record(new PlatformAdminAuditLogDraft( + actor, + targetType, + targetId, + action, + toJson(before), + toJson(after))); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new AdminOperationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_AUDIT_SERIALIZE_FAILED", + "系统管理审计序列化失败。"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserCreateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserCreateRequest.java new file mode 100644 index 0000000..c37d081 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserCreateRequest.java @@ -0,0 +1,35 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 管理后台新增用户请求。 + */ +public record AdminUserCreateRequest( + /** 登录用户名,全局唯一。 */ + String username, + /** 初始密码,只用于本次创建,不写入审计和日志。 */ + @JsonProperty("initial_password") + String initialPassword, + /** 用户展示名称。 */ + @JsonProperty("display_name") + String displayName, + /** 用户邮箱。 */ + String email, + /** 用户手机号。 */ + String phone, + /** 用户状态,默认 ACTIVE。 */ + @JsonProperty("user_status") + String userStatus, + /** 角色内部 ID 列表。 */ + @JsonProperty("role_ids") + List roleIds, + /** 授权酒店业务 ID 列表。 */ + @JsonProperty("hotel_ids") + List hotelIds, + /** 默认酒店业务 ID。 */ + @JsonProperty("default_hotel_id") + String defaultHotelId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserHotelAssignmentRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserHotelAssignmentRequest.java new file mode 100644 index 0000000..b9aaa75 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserHotelAssignmentRequest.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 管理后台覆盖用户酒店授权请求。 + */ +public record AdminUserHotelAssignmentRequest( + /** 授权酒店业务 ID 列表。 */ + @JsonProperty("hotel_ids") + List hotelIds, + /** 默认酒店业务 ID,必须包含在授权酒店列表内。 */ + @JsonProperty("default_hotel_id") + String defaultHotelId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserPasswordResetRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserPasswordResetRequest.java new file mode 100644 index 0000000..398044f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserPasswordResetRequest.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台重置用户密码请求。newPassword 为空时由后端生成临时密码。 + */ +public record AdminUserPasswordResetRequest( + /** 可选新密码,空值时自动生成。 */ + @JsonProperty("new_password") + String newPassword +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserRoleAssignmentRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserRoleAssignmentRequest.java new file mode 100644 index 0000000..f3c69ae --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserRoleAssignmentRequest.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 管理后台覆盖用户角色请求。 + */ +public record AdminUserRoleAssignmentRequest( + /** 角色内部 ID 列表。 */ + @JsonProperty("role_ids") + List roleIds +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserUpdateRequest.java new file mode 100644 index 0000000..0463f44 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AdminUserUpdateRequest.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台编辑用户基础信息请求。 + */ +public record AdminUserUpdateRequest( + /** 用户展示名称。 */ + @JsonProperty("display_name") + String displayName, + /** 用户邮箱。 */ + String email, + /** 用户手机号。 */ + String phone, + /** 用户状态:ACTIVE、DISABLED。 */ + @JsonProperty("user_status") + String userStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserDetailResult.java new file mode 100644 index 0000000..65a6288 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserDetailResult.java @@ -0,0 +1,45 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 管理后台用户详情。 + */ +public record AdminUserDetailResult( + /** 用户内部 ID 字符串。 */ + String id, + /** 登录用户名。 */ + String username, + /** 用户展示名称。 */ + @JsonProperty("display_name") + String displayName, + /** 用户邮箱。 */ + String email, + /** 用户手机号。 */ + String phone, + /** 用户状态。 */ + @JsonProperty("user_status") + String userStatus, + /** 是否超级管理员。 */ + @JsonProperty("super_admin") + Boolean superAdmin, + /** 最近密码变更 UTC 时间。 */ + @JsonProperty("password_changed_at") + OffsetDateTime passwordChangedAt, + /** 最近登录 UTC 时间。 */ + @JsonProperty("last_login_at") + OffsetDateTime lastLoginAt, + /** 创建 UTC 时间。 */ + @JsonProperty("created_at") + OffsetDateTime createdAt, + /** 更新 UTC 时间。 */ + @JsonProperty("updated_at") + OffsetDateTime updatedAt, + /** 角色列表。 */ + List roles, + /** 酒店授权列表。 */ + List hotels +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserHotelResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserHotelResult.java new file mode 100644 index 0000000..06527a2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserHotelResult.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台用户酒店授权摘要。 + */ +public record AdminUserHotelResult( + /** 酒店业务 ID。 */ + @JsonProperty("hotel_id") + String hotelId, + /** 酒店展示名称。 */ + @JsonProperty("hotel_name") + String hotelName, + /** 酒店状态。 */ + @JsonProperty("hotel_status") + String hotelStatus, + /** 是否该用户默认酒店。 */ + @JsonProperty("default_hotel") + Boolean defaultHotel +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserListItemResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserListItemResult.java new file mode 100644 index 0000000..21ea691 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserListItemResult.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 管理后台用户列表项。 + */ +public record AdminUserListItemResult( + /** 用户内部 ID 字符串。 */ + String id, + /** 登录用户名。 */ + String username, + /** 用户展示名称。 */ + @JsonProperty("display_name") + String displayName, + /** 用户邮箱。 */ + String email, + /** 用户手机号。 */ + String phone, + /** 用户状态。 */ + @JsonProperty("user_status") + String userStatus, + /** 是否超级管理员。 */ + @JsonProperty("super_admin") + Boolean superAdmin, + /** 当前角色摘要。 */ + List roles, + /** 默认酒店业务 ID。 */ + @JsonProperty("default_hotel_id") + String defaultHotelId, + /** 最近登录 UTC 时间。 */ + @JsonProperty("last_login_at") + OffsetDateTime lastLoginAt, + /** 更新时间 UTC 时间。 */ + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserPasswordResetResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserPasswordResetResult.java new file mode 100644 index 0000000..130fe48 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserPasswordResetResult.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台密码重置结果。临时密码只在本次响应返回。 + */ +public record AdminUserPasswordResetResult( + /** 用户内部 ID 字符串。 */ + @JsonProperty("user_id") + String userId, + /** 登录用户名。 */ + String username, + /** 临时密码,只允许前端当次展示。 */ + @JsonProperty("temporary_password") + String temporaryPassword +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserRoleResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserRoleResult.java new file mode 100644 index 0000000..783a7ad --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AdminUserRoleResult.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台用户角色摘要。 + */ +public record AdminUserRoleResult( + /** 角色内部 ID 字符串。 */ + String id, + /** 稳定角色代码。 */ + @JsonProperty("role_code") + String roleCode, + /** 角色展示名称。 */ + @JsonProperty("role_name") + String roleName, + /** 是否系统内置角色。 */ + @JsonProperty("system_builtin") + Boolean systemBuiltin +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AdminUserController.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AdminUserController.java new file mode 100644 index 0000000..22ce32a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AdminUserController.java @@ -0,0 +1,125 @@ +package cn.nianxx.thhotel.platform.identity.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserCreateRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserHotelAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserPasswordResetRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserRoleAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserUpdateRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserDetailResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserListItemResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserPasswordResetResult; +import cn.nianxx.thhotel.platform.identity.service.AdminUserManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台用户 Controller。提供用户只读查询和后续账号维护入口。 + */ +@RestController +@RequestMapping("/api/admin/users") +public class AdminUserController { + + private final AdminAuthorizationService authorizationService; + private final AdminUserManagementService userManagementService; + + /** + * 注入管理鉴权和用户管理服务。 + */ + public AdminUserController( + AdminAuthorizationService authorizationService, + AdminUserManagementService userManagementService) { + this.authorizationService = authorizationService; + this.userManagementService = userManagementService; + } + + /** + * 分页查询用户列表,需要用户管理权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listUsers( + @RequestParam(required = false) String keyword, + @RequestParam(name = "user_status", required = false) String userStatus, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.queryUsers(keyword, userStatus, pageNum, pageSize); + } + + /** + * 查询单个用户详情,需要用户管理权限。 + */ + @GetMapping(value = "/{userId}", produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserDetailResult getUser(@PathVariable String userId) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.getUser(userId); + } + + /** + * 新增普通用户,需要用户管理权限。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserDetailResult createUser(@RequestBody(required = false) AdminUserCreateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.createUser(request, actor); + } + + /** + * 编辑用户基础信息和状态,需要用户管理权限。 + */ + @PutMapping(value = "/{userId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserDetailResult updateUser( + @PathVariable String userId, + @RequestBody(required = false) AdminUserUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.updateUser(userId, request, actor); + } + + /** + * 覆盖用户角色,需要用户管理权限。 + */ + @PutMapping(value = "/{userId}/roles", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserDetailResult assignRoles( + @PathVariable String userId, + @RequestBody(required = false) AdminUserRoleAssignmentRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.assignRoles(userId, request, actor); + } + + /** + * 覆盖用户酒店授权,需要用户管理权限。 + */ + @PutMapping(value = "/{userId}/hotels", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserDetailResult assignHotels( + @PathVariable String userId, + @RequestBody(required = false) AdminUserHotelAssignmentRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.assignHotels(userId, request, actor); + } + + /** + * 重置用户密码,需要用户管理权限,临时密码只在本次响应返回。 + */ + @PostMapping(value = "/{userId}/password-reset", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminUserPasswordResetResult resetPassword( + @PathVariable String userId, + @RequestBody(required = false) AdminUserPasswordResetRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_USER_MANAGE.name()); + return userManagementService.resetPassword(userId, request, actor); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java index 3871538..d6ede87 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java @@ -1,10 +1,16 @@ package cn.nianxx.thhotel.platform.identity.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserSessionStatus; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; import cn.nianxx.thhotel.platform.identity.mapper.PlatformUserMapper; import cn.nianxx.thhotel.platform.identity.mapper.PlatformUserSessionMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -39,10 +45,28 @@ public class MybatisPlatformIdentityRepository implements PlatformIdentityReposi return Optional.ofNullable(userMapper.selectById(userId)); } + @Override + public PlatformPageSnapshot queryUsers(String keyword, String userStatus, int pageNum, int pageSize) { + Page page = userMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(userStatus != null && !userStatus.isBlank(), PlatformUserEntity::getUserStatus, userStatus) + .and(keyword != null && !keyword.isBlank(), wrapper -> wrapper + .like(PlatformUserEntity::getUsername, keyword) + .or() + .like(PlatformUserEntity::getDisplayName, keyword) + .or() + .like(PlatformUserEntity::getEmail, keyword) + .or() + .like(PlatformUserEntity::getPhone, keyword)) + .orderByAsc(PlatformUserEntity::getUsername)); + return new PlatformPageSnapshot<>(page.getRecords(), page.getTotal(), pageNum, pageSize); + } + @Override public long countSuperAdminUsers() { return userMapper.selectCount(Wrappers.lambdaQuery() - .eq(PlatformUserEntity::getSuperAdmin, true)); + .eq(PlatformUserEntity::getSuperAdmin, true) + .eq(PlatformUserEntity::getUserStatus, PlatformUserStatus.ACTIVE.name())); } @Override @@ -71,4 +95,19 @@ public class MybatisPlatformIdentityRepository implements PlatformIdentityReposi public void updateSession(PlatformUserSessionEntity session) { sessionMapper.updateById(session); } + + @Override + public void revokeActiveSessionsByUserId(Long userId) { + if (userId == null) { + return; + } + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + PlatformUserSessionEntity update = new PlatformUserSessionEntity(); + update.setSessionStatus(PlatformUserSessionStatus.REVOKED.name()); + update.setRevokedAt(now); + update.setUpdatedAt(now); + sessionMapper.update(update, Wrappers.lambdaUpdate() + .eq(PlatformUserSessionEntity::getUserId, userId) + .eq(PlatformUserSessionEntity::getSessionStatus, PlatformUserSessionStatus.ACTIVE.name())); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java index b6a0f86..bd25cf5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.platform.identity.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; import java.util.Optional; @@ -20,7 +21,12 @@ public interface PlatformIdentityRepository { Optional findUserById(Long userId); /** - * 统计系统中已有超级管理员数量。 + * 管理后台分页查询用户账号。 + */ + PlatformPageSnapshot queryUsers(String keyword, String userStatus, int pageNum, int pageSize); + + /** + * 统计系统中已有可登录的启用超级管理员数量。 */ long countSuperAdminUsers(); @@ -48,4 +54,9 @@ public interface PlatformIdentityRepository { * 更新用户 session。 */ void updateSession(PlatformUserSessionEntity session); + + /** + * 撤销指定用户全部 ACTIVE session。 + */ + void revokeActiveSessionsByUserId(Long userId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AdminUserManagementService.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AdminUserManagementService.java new file mode 100644 index 0000000..883f28f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AdminUserManagementService.java @@ -0,0 +1,60 @@ +package cn.nianxx.thhotel.platform.identity.service; + +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserCreateRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserHotelAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserPasswordResetRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserRoleAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserUpdateRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserDetailResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserListItemResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserPasswordResetResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 管理后台用户管理服务。提供用户查询和后续账号维护能力。 + */ +public interface AdminUserManagementService { + + /** + * 分页查询用户列表。 + */ + PlatformPageResult queryUsers( + String keyword, + String userStatus, + Integer pageNum, + Integer pageSize); + + /** + * 查询用户详情。 + */ + AdminUserDetailResult getUser(String userId); + + /** + * 新增普通用户账号。 + */ + AdminUserDetailResult createUser(AdminUserCreateRequest request, AuthenticatedUserContext actor); + + /** + * 编辑用户基础信息和状态。 + */ + AdminUserDetailResult updateUser(String userId, AdminUserUpdateRequest request, AuthenticatedUserContext actor); + + /** + * 覆盖用户角色关系。 + */ + AdminUserDetailResult assignRoles(String userId, AdminUserRoleAssignmentRequest request, AuthenticatedUserContext actor); + + /** + * 覆盖用户酒店授权和默认酒店。 + */ + AdminUserDetailResult assignHotels(String userId, AdminUserHotelAssignmentRequest request, AuthenticatedUserContext actor); + + /** + * 重置用户密码,临时密码只在响应返回。 + */ + AdminUserPasswordResetResult resetPassword( + String userId, + AdminUserPasswordResetRequest request, + AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AdminUserManagementServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AdminUserManagementServiceImpl.java new file mode 100644 index 0000000..5e6d859 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AdminUserManagementServiceImpl.java @@ -0,0 +1,566 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserCreateRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserHotelAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserPasswordResetRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserRoleAssignmentRequest; +import cn.nianxx.thhotel.platform.identity.common.request.AdminUserUpdateRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserDetailResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserHotelResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserListItemResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserPasswordResetResult; +import cn.nianxx.thhotel.platform.identity.common.result.AdminUserRoleResult; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.AdminUserManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 管理后台用户管理服务实现。聚合用户、角色和酒店授权信息供前端展示。 + */ +@Service +public class AdminUserManagementServiceImpl implements AdminUserManagementService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final String TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$%"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final PlatformIdentityRepository identityRepository; + private final PlatformAccessRepository accessRepository; + private final PlatformHotelRepository hotelRepository; + private final AuthPasswordService passwordService; + private final PlatformAdminAuditLogService auditLogService; + private final ObjectMapper objectMapper; + + /** + * 注入用户、角色权限和酒店仓储。 + */ + public AdminUserManagementServiceImpl( + PlatformIdentityRepository identityRepository, + PlatformAccessRepository accessRepository, + PlatformHotelRepository hotelRepository, + AuthPasswordService passwordService, + PlatformAdminAuditLogService auditLogService, + ObjectMapper objectMapper) { + this.identityRepository = identityRepository; + this.accessRepository = accessRepository; + this.hotelRepository = hotelRepository; + this.passwordService = passwordService; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; + } + + /** + * 分页查询用户列表,并补齐角色摘要和默认酒店。 + */ + @Override + public PlatformPageResult queryUsers( + String keyword, + String userStatus, + Integer pageNum, + Integer pageSize) { + int normalizedPageNum = normalizePageNum(pageNum); + int normalizedPageSize = normalizePageSize(pageSize); + PlatformPageSnapshot page = identityRepository.queryUsers( + trimToNull(keyword), + trimToNull(userStatus), + normalizedPageNum, + normalizedPageSize); + List userIds = page.items().stream().map(PlatformUserEntity::getId).toList(); + Map> rolesByUserId = rolesByUserId(userIds); + Map> hotelRelationsByUserId = hotelRelationsByUserId(userIds); + List items = page.items().stream() + .map(user -> toListItem(user, rolesByUserId, hotelRelationsByUserId)) + .toList(); + return new PlatformPageResult<>( + items, + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 查询用户详情,包含角色和授权酒店。 + */ + @Override + public AdminUserDetailResult getUser(String userId) { + Long id = parseId(userId, "用户 ID 不合法。"); + PlatformUserEntity user = identityRepository.findUserById(id) + .orElseThrow(() -> notFound("用户不存在。")); + Map> rolesByUserId = rolesByUserId(List.of(id)); + List hotels = hotelsForUser(id); + return new AdminUserDetailResult( + stringId(user.getId()), + user.getUsername(), + user.getDisplayName(), + user.getEmail(), + user.getPhone(), + user.getUserStatus(), + user.getSuperAdmin(), + UtcTimeFormatter.toUtcOffsetDateTime(user.getPasswordChangedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(user.getLastLoginAt()), + UtcTimeFormatter.toUtcOffsetDateTime(user.getCreatedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(user.getUpdatedAt()), + rolesByUserId.getOrDefault(id, Collections.emptyList()), + hotels); + } + + /** + * 新增普通用户账号,并可同步写入初始角色和酒店授权。 + */ + @Override + @Transactional + public AdminUserDetailResult createUser(AdminUserCreateRequest request, AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("用户创建请求不能为空。"); + } + String username = requireCodeLike(request.username(), "用户名不能为空。"); + if (identityRepository.findUserByUsername(username).isPresent()) { + throw conflict("用户名已存在。"); + } + String rawPassword = requireText(request.initialPassword(), "初始密码不能为空。"); + validatePassword(rawPassword); + String status = normalizeStatus(request.userStatus(), PlatformUserStatus.ACTIVE.name()); + validateUserStatus(status); + + LocalDateTime now = nowUtc(); + PlatformUserEntity user = new PlatformUserEntity(); + user.setUsername(username); + user.setPasswordHash(passwordService.hash(rawPassword)); + user.setDisplayName(defaultIfBlank(request.displayName(), username)); + user.setEmail(trimToNull(request.email())); + user.setPhone(trimToNull(request.phone())); + user.setUserStatus(status); + user.setSuperAdmin(false); + user.setPasswordChangedAt(now); + user.setCreatedAt(now); + user.setUpdatedAt(now); + identityRepository.insertUser(user); + + List roleIds = parseIdList(request.roleIds(), "角色 ID 不合法。"); + validateRolesExist(roleIds); + accessRepository.replaceUserRoles(user.getId(), roleIds); + + List hotelIds = normalizeHotelIds(request.hotelIds()); + validateUserHotels(hotelIds, request.defaultHotelId()); + hotelRepository.replaceUserHotels(user.getId(), hotelIds, trimToNull(request.defaultHotelId())); + + AdminUserDetailResult after = getUser(stringId(user.getId())); + audit(actor, "PLATFORM_USER", stringId(user.getId()), "CREATE_USER", null, after); + return after; + } + + /** + * 编辑用户基础信息。禁用用户时会撤销该用户全部 ACTIVE session。 + */ + @Override + @Transactional + public AdminUserDetailResult updateUser( + String userId, + AdminUserUpdateRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("用户更新请求不能为空。"); + } + Long id = parseId(userId, "用户 ID 不合法。"); + AdminUserDetailResult before = getUser(userId); + PlatformUserEntity user = identityRepository.findUserById(id) + .orElseThrow(() -> notFound("用户不存在。")); + String status = normalizeStatus(request.userStatus(), user.getUserStatus()); + validateUserStatus(status); + if (Boolean.TRUE.equals(user.getSuperAdmin()) + && PlatformUserStatus.DISABLED.name().equals(status) + && identityRepository.countSuperAdminUsers() <= 1) { + throw conflict("不能禁用最后一个启用的超级管理员。"); + } + user.setDisplayName(defaultIfBlank(request.displayName(), user.getUsername())); + user.setEmail(trimToNull(request.email())); + user.setPhone(trimToNull(request.phone())); + boolean disableUser = PlatformUserStatus.DISABLED.name().equals(status) + && !PlatformUserStatus.DISABLED.name().equals(user.getUserStatus()); + user.setUserStatus(status); + user.setUpdatedAt(nowUtc()); + identityRepository.updateUser(user); + if (disableUser) { + identityRepository.revokeActiveSessionsByUserId(user.getId()); + } + AdminUserDetailResult after = getUser(userId); + audit(actor, "PLATFORM_USER", userId, "UPDATE_USER", before, after); + return after; + } + + /** + * 覆盖用户角色关系。超级管理员标记不通过角色分配接口变更。 + */ + @Override + @Transactional + public AdminUserDetailResult assignRoles( + String userId, + AdminUserRoleAssignmentRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("用户角色分配请求不能为空。"); + } + Long id = parseId(userId, "用户 ID 不合法。"); + identityRepository.findUserById(id).orElseThrow(() -> notFound("用户不存在。")); + AdminUserDetailResult before = getUser(userId); + List roleIds = parseIdList(request.roleIds(), "角色 ID 不合法。"); + validateRolesExist(roleIds); + accessRepository.replaceUserRoles(id, roleIds); + AdminUserDetailResult after = getUser(userId); + audit(actor, "PLATFORM_USER", userId, "ASSIGN_USER_ROLES", before, after); + return after; + } + + /** + * 覆盖普通用户酒店授权。默认酒店必须属于授权列表且必须是启用酒店。 + */ + @Override + @Transactional + public AdminUserDetailResult assignHotels( + String userId, + AdminUserHotelAssignmentRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("用户酒店授权请求不能为空。"); + } + Long id = parseId(userId, "用户 ID 不合法。"); + identityRepository.findUserById(id).orElseThrow(() -> notFound("用户不存在。")); + AdminUserDetailResult before = getUser(userId); + List hotelIds = normalizeHotelIds(request.hotelIds()); + validateUserHotels(hotelIds, request.defaultHotelId()); + hotelRepository.replaceUserHotels(id, hotelIds, trimToNull(request.defaultHotelId())); + AdminUserDetailResult after = getUser(userId); + audit(actor, "PLATFORM_USER", userId, "ASSIGN_USER_HOTELS", before, after); + return after; + } + + /** + * 重置用户密码。明文临时密码只在本次响应返回,审计里只记录发生过重置。 + */ + @Override + @Transactional + public AdminUserPasswordResetResult resetPassword( + String userId, + AdminUserPasswordResetRequest request, + AuthenticatedUserContext actor) { + Long id = parseId(userId, "用户 ID 不合法。"); + PlatformUserEntity user = identityRepository.findUserById(id) + .orElseThrow(() -> notFound("用户不存在。")); + String temporaryPassword = trimToNull(request == null ? null : request.newPassword()); + if (temporaryPassword == null) { + temporaryPassword = generateTemporaryPassword(); + } + validatePassword(temporaryPassword); + AdminUserDetailResult before = getUser(userId); + user.setPasswordHash(passwordService.hash(temporaryPassword)); + user.setPasswordChangedAt(nowUtc()); + user.setUpdatedAt(nowUtc()); + identityRepository.updateUser(user); + identityRepository.revokeActiveSessionsByUserId(id); + audit(actor, "PLATFORM_USER", userId, "RESET_USER_PASSWORD", before, Map.of( + "user_id", userId, + "username", user.getUsername(), + "password_reset", true)); + return new AdminUserPasswordResetResult(userId, user.getUsername(), temporaryPassword); + } + + /** + * 将用户实体转换为列表项。 + */ + private AdminUserListItemResult toListItem( + PlatformUserEntity user, + Map> rolesByUserId, + Map> hotelRelationsByUserId) { + String defaultHotelId = hotelRelationsByUserId.getOrDefault(user.getId(), Collections.emptyList()) + .stream() + .filter(relation -> Boolean.TRUE.equals(relation.getDefaultHotel())) + .map(PlatformUserHotelEntity::getHotelId) + .findFirst() + .orElse(null); + return new AdminUserListItemResult( + stringId(user.getId()), + user.getUsername(), + user.getDisplayName(), + user.getEmail(), + user.getPhone(), + user.getUserStatus(), + user.getSuperAdmin(), + rolesByUserId.getOrDefault(user.getId(), Collections.emptyList()), + defaultHotelId, + UtcTimeFormatter.toUtcOffsetDateTime(user.getLastLoginAt()), + UtcTimeFormatter.toUtcOffsetDateTime(user.getUpdatedAt())); + } + + /** + * 查询指定用户集合的角色摘要。 + */ + private Map> rolesByUserId(List userIds) { + List relations = accessRepository.listUserRolesByUserIds(userIds); + Set roleIds = relations.stream() + .map(PlatformUserRoleEntity::getRoleId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map roleById = accessRepository.listRolesByIds(roleIds.stream().toList()).stream() + .collect(Collectors.toMap(PlatformRoleEntity::getId, role -> role)); + Map> grouped = new LinkedHashMap<>(); + for (PlatformUserRoleEntity relation : relations) { + PlatformRoleEntity role = roleById.get(relation.getRoleId()); + if (role == null) { + continue; + } + grouped.computeIfAbsent(relation.getUserId(), ignored -> new java.util.ArrayList<>()) + .add(new AdminUserRoleResult( + stringId(role.getId()), + role.getRoleCode(), + role.getRoleName(), + role.getSystemBuiltin())); + } + return grouped; + } + + /** + * 查询用户酒店授权关系并按用户分组。 + */ + private Map> hotelRelationsByUserId(List userIds) { + return hotelRepository.listUserHotelRelationsByUserIds(userIds).stream() + .collect(Collectors.groupingBy( + PlatformUserHotelEntity::getUserId, + LinkedHashMap::new, + Collectors.toList())); + } + + /** + * 查询指定用户的授权酒店展示信息。 + */ + private List hotelsForUser(Long userId) { + List relations = hotelRepository.listUserHotelRelationsByUserIds(List.of(userId)); + Map hotelByHotelId = hotelRepository.listHotelsByHotelIds(relations.stream() + .map(PlatformUserHotelEntity::getHotelId) + .toList()) + .stream() + .collect(Collectors.toMap(PlatformHotelEntity::getHotelId, hotel -> hotel)); + return relations.stream() + .map(relation -> { + PlatformHotelEntity hotel = hotelByHotelId.get(relation.getHotelId()); + return new AdminUserHotelResult( + relation.getHotelId(), + hotel == null ? relation.getHotelId() : hotel.getHotelName(), + hotel == null ? null : hotel.getHotelStatus(), + relation.getDefaultHotel()); + }) + .toList(); + } + + private int normalizePageNum(Integer pageNum) { + return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private Long parseId(String id, String message) { + try { + return Long.valueOf(id); + } catch (NumberFormatException exception) { + throw new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + } + + private AdminOperationException notFound(String message) { + return new AdminOperationException(HttpStatus.NOT_FOUND, "ADMIN_TARGET_NOT_FOUND", message); + } + + private AdminOperationException invalidRequest(String message) { + return new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + + private AdminOperationException conflict(String message) { + return new AdminOperationException(HttpStatus.CONFLICT, "ADMIN_CONFLICT", message); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } + + private String requireText(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + return normalized; + } + + private String requireCodeLike(String value, String message) { + String normalized = requireText(value, message); + if (!normalized.matches("[A-Za-z0-9_.@-]{3,128}")) { + throw invalidRequest(message); + } + return normalized; + } + + private String defaultIfBlank(String value, String fallback) { + String normalized = trimToNull(value); + return normalized == null ? fallback : normalized; + } + + private String normalizeStatus(String value, String defaultStatus) { + String normalized = trimToNull(value); + return normalized == null ? defaultStatus : normalized.toUpperCase(java.util.Locale.ROOT); + } + + private void validateUserStatus(String status) { + if (!PlatformUserStatus.ACTIVE.name().equals(status) && !PlatformUserStatus.DISABLED.name().equals(status)) { + throw invalidRequest("用户状态不合法。"); + } + } + + private void validatePassword(String rawPassword) { + if (rawPassword == null || rawPassword.length() < 8 || rawPassword.length() > 128) { + throw invalidRequest("密码长度必须在 8 到 128 位之间。"); + } + } + + private List parseIdList(List ids, String message) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (String id : ids) { + result.add(parseId(id, message)); + } + return new ArrayList<>(new LinkedHashSet<>(result)); + } + + private void validateRolesExist(List roleIds) { + if (roleIds == null || roleIds.isEmpty()) { + return; + } + Set existingRoleIds = accessRepository.listRolesByIds(roleIds).stream() + .map(PlatformRoleEntity::getId) + .collect(Collectors.toSet()); + if (!existingRoleIds.containsAll(roleIds)) { + throw invalidRequest("存在不存在的角色 ID。"); + } + } + + private List normalizeHotelIds(List hotelIds) { + if (hotelIds == null) { + return Collections.emptyList(); + } + return hotelIds.stream() + .map(this::trimToNull) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + + private void validateUserHotels(List hotelIds, String defaultHotelId) { + String normalizedDefaultHotelId = trimToNull(defaultHotelId); + if (hotelIds == null || hotelIds.isEmpty()) { + throw invalidRequest("用户至少需要授权一个酒店。"); + } + if (normalizedDefaultHotelId == null || !hotelIds.contains(normalizedDefaultHotelId)) { + throw invalidRequest("默认酒店必须在授权酒店列表内。"); + } + Map hotelById = hotelRepository.listHotelsByHotelIds(hotelIds).stream() + .collect(Collectors.toMap(PlatformHotelEntity::getHotelId, hotel -> hotel)); + if (!hotelById.keySet().containsAll(hotelIds)) { + throw invalidRequest("存在不存在的酒店 ID。"); + } + boolean hasDisabledHotel = hotelById.values().stream() + .anyMatch(hotel -> !PlatformHotelStatus.ACTIVE.name().equals(hotel.getHotelStatus())); + if (hasDisabledHotel) { + throw invalidRequest("用户授权酒店必须全部是启用酒店。"); + } + PlatformHotelEntity defaultHotel = hotelById.get(normalizedDefaultHotelId); + if (defaultHotel == null || !PlatformHotelStatus.ACTIVE.name().equals(defaultHotel.getHotelStatus())) { + throw invalidRequest("默认酒店必须是启用酒店。"); + } + } + + private String generateTemporaryPassword() { + StringBuilder builder = new StringBuilder(14); + for (int i = 0; i < 14; i++) { + builder.append(TEMP_PASSWORD_CHARS.charAt(SECURE_RANDOM.nextInt(TEMP_PASSWORD_CHARS.length()))); + } + return builder.toString(); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private void audit( + AuthenticatedUserContext actor, + String targetType, + String targetId, + String action, + Object before, + Object after) { + auditLogService.record(new PlatformAdminAuditLogDraft( + actor, + targetType, + targetId, + action, + toJson(before), + toJson(after))); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new AdminOperationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_AUDIT_SERIALIZE_FAILED", + "系统管理审计序列化失败。"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index 47830d9..4af1e4a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -73,6 +73,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { seedBootstrapAdmin(); } + /** + * 初始化内置权限码;权限码以枚举为权威来源,启动时同步展示名和分组。 + */ private void seedPermissions() { for (PlatformPermissionCode code : PlatformPermissionCode.values()) { PlatformPermissionEntity permission = accessRepository.findPermissionByCode(code.name()) @@ -92,6 +95,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { } } + /** + * 初始化内置角色;第一版角色定义由代码维护,后续管理后台再扩展维护边界。 + */ private void seedRoles() { for (PlatformRoleCode code : PlatformRoleCode.values()) { PlatformRoleEntity role = accessRepository.findRoleByCode(code.name()) @@ -110,19 +116,26 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { } } + /** + * 同步内置角色权限矩阵;矩阵中移除的权限关系会在启动时清理。 + */ private void seedRolePermissions() { Map> matrix = rolePermissionMatrix(); for (Map.Entry> entry : matrix.entrySet()) { PlatformRoleEntity role = accessRepository.findRoleByCode(entry.getKey().name()) .orElseThrow(() -> new IllegalStateException("role not seeded: " + entry.getKey())); - for (PlatformPermissionCode permissionCode : entry.getValue()) { - PlatformPermissionEntity permission = accessRepository.findPermissionByCode(permissionCode.name()) - .orElseThrow(() -> new IllegalStateException("permission not seeded: " + permissionCode)); - accessRepository.ensureRolePermission(role.getId(), permission.getId()); - } + List permissionIds = entry.getValue().stream() + .map(permissionCode -> accessRepository.findPermissionByCode(permissionCode.name()) + .orElseThrow(() -> new IllegalStateException("permission not seeded: " + permissionCode)) + .getId()) + .toList(); + accessRepository.syncRolePermissions(role.getId(), permissionIds); } } + /** + * 初始化前端可见菜单和隐藏占位菜单,保持后端菜单契约与当前前端路由一致。 + */ private void seedMenus() { upsertMenu( PlatformMenuCode.RESERVATION_ORDERS, @@ -166,11 +179,14 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { "/system", "SystemSettings", "pi pi-cog", - PlatformPermissionCode.SYSTEM_USER_MANAGE.name(), + PlatformPermissionCode.SYSTEM_ADMIN_CONSOLE_ACCESS.name(), 100, - false); + true); } + /** + * 初始化默认酒店基础资料,供首个管理员和本地测试环境使用。 + */ private void seedDefaultHotel() { String defaultHotelId = authProperties.getBootstrap().getDefaultHotelId(); if (isBlank(defaultHotelId)) { @@ -192,6 +208,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { } } + /** + * 初始化首个可登录超级管理员;只有不存在启用超级管理员时才使用环境变量。 + */ private void seedBootstrapAdmin() { AuthProperties.Admin admin = authProperties.getBootstrap().getAdmin(); if (isBlank(admin.getUsername()) || isBlank(admin.getPassword()) || identityRepository.countSuperAdminUsers() > 0) { @@ -224,6 +243,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { } } + /** + * 新增或更新单个菜单定义,菜单代码为稳定唯一标识。 + */ private void upsertMenu( PlatformMenuCode code, String name, @@ -254,6 +276,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { } } + /** + * 返回第一版内置角色权限矩阵,启动同步时以该矩阵为准。 + */ private Map> rolePermissionMatrix() { Map> matrix = new EnumMap<>(PlatformRoleCode.class); matrix.put(PlatformRoleCode.SYSTEM_ADMIN, List.of(PlatformPermissionCode.values())); @@ -278,6 +303,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { return matrix; } + /** + * 解析权限码中文展示名;业务判断仍只使用稳定权限码。 + */ private String permissionName(PlatformPermissionCode code) { return switch (code) { case SOURCE_MESSAGE_READ -> "读取邮件消息"; @@ -294,10 +322,14 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { case SYSTEM_ROLE_MANAGE -> "管理角色"; case SYSTEM_MENU_MANAGE -> "管理菜单"; case HOTEL_MANAGE -> "管理酒店"; + case SYSTEM_ADMIN_CONSOLE_ACCESS -> "进入系统管理后台"; case SYSTEM_DEBUG_EML_RUN -> "运行 Debug EML"; }; } + /** + * 解析权限所属分组,便于后续管理后台按业务域展示。 + */ private String permissionGroup(PlatformPermissionCode code) { return switch (code) { case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE"; @@ -309,6 +341,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { }; } + /** + * 解析内置角色中文展示名。 + */ private String roleName(PlatformRoleCode code) { return switch (code) { case SYSTEM_ADMIN -> "系统管理员"; @@ -317,6 +352,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { }; } + /** + * 设置权限记录创建和更新 UTC 时间。 + */ private void setAuditTime(PlatformPermissionEntity entity, boolean creating) { LocalDateTime now = nowUtc(); if (creating) { @@ -325,6 +363,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { entity.setUpdatedAt(now); } + /** + * 设置角色记录创建和更新 UTC 时间。 + */ private void setAuditTime(PlatformRoleEntity entity, boolean creating) { LocalDateTime now = nowUtc(); if (creating) { @@ -333,6 +374,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { entity.setUpdatedAt(now); } + /** + * 设置酒店记录创建和更新 UTC 时间。 + */ private void setAuditTime(PlatformHotelEntity entity, boolean creating) { LocalDateTime now = nowUtc(); if (creating) { @@ -341,6 +385,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { entity.setUpdatedAt(now); } + /** + * 设置菜单记录创建和更新 UTC 时间。 + */ private void setAuditTime(PlatformMenuEntity entity, boolean creating) { LocalDateTime now = nowUtc(); if (creating) { @@ -349,6 +396,9 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { entity.setUpdatedAt(now); } + /** + * 设置用户记录创建和更新 UTC 时间。 + */ private void setAuditTime(PlatformUserEntity entity, boolean creating) { LocalDateTime now = nowUtc(); if (creating) { @@ -357,14 +407,23 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { entity.setUpdatedAt(now); } + /** + * 获取 UTC 当前时间,保证启动数据时间语义统一。 + */ private LocalDateTime nowUtc() { return LocalDateTime.now(ZoneOffset.UTC); } + /** + * 判断配置字符串是否为空白。 + */ private boolean isBlank(String value) { return value == null || value.isBlank(); } + /** + * 返回非空配置值,否则使用兜底值。 + */ private String defaultString(String value, String fallback) { return isBlank(value) ? fallback : value; } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuCreateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuCreateRequest.java new file mode 100644 index 0000000..1e2ea29 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuCreateRequest.java @@ -0,0 +1,42 @@ +package cn.nianxx.thhotel.platform.navigation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台新增菜单请求。 + */ +public record AdminMenuCreateRequest( + /** 父菜单内部 ID 字符串。 */ + @JsonProperty("parent_id") + String parentId, + /** 稳定菜单代码,全局唯一。 */ + @JsonProperty("menu_code") + String menuCode, + /** 菜单展示名称。 */ + @JsonProperty("menu_name") + String menuName, + /** 菜单类型,默认 PAGE。 */ + @JsonProperty("menu_type") + String menuType, + /** 前端路由路径,可为未知路由。 */ + @JsonProperty("route_path") + String routePath, + /** 前端组件标识。 */ + @JsonProperty("component_key") + String componentKey, + /** 前端图标标识。 */ + @JsonProperty("icon_key") + String iconKey, + /** 菜单入口权限码。 */ + @JsonProperty("permission_code") + String permissionCode, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder, + /** 是否作为菜单入口可见。 */ + Boolean visible, + /** 菜单状态,默认 DISABLED。 */ + @JsonProperty("menu_status") + String menuStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuUpdateRequest.java new file mode 100644 index 0000000..6d20a1d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuUpdateRequest.java @@ -0,0 +1,39 @@ +package cn.nianxx.thhotel.platform.navigation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 管理后台编辑菜单请求。菜单代码新增后不允许修改。 + */ +public record AdminMenuUpdateRequest( + /** 父菜单内部 ID 字符串。 */ + @JsonProperty("parent_id") + String parentId, + /** 菜单展示名称。 */ + @JsonProperty("menu_name") + String menuName, + /** 菜单类型。 */ + @JsonProperty("menu_type") + String menuType, + /** 前端路由路径。 */ + @JsonProperty("route_path") + String routePath, + /** 前端组件标识。 */ + @JsonProperty("component_key") + String componentKey, + /** 前端图标标识。 */ + @JsonProperty("icon_key") + String iconKey, + /** 菜单入口权限码。 */ + @JsonProperty("permission_code") + String permissionCode, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder, + /** 是否作为菜单入口可见。 */ + Boolean visible, + /** 菜单状态:ACTIVE、DISABLED。 */ + @JsonProperty("menu_status") + String menuStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuResult.java new file mode 100644 index 0000000..d758359 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuResult.java @@ -0,0 +1,54 @@ +package cn.nianxx.thhotel.platform.navigation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 管理后台菜单结果。 + */ +public record AdminMenuResult( + /** 菜单内部 ID 字符串。 */ + String id, + /** 父菜单内部 ID 字符串。 */ + @JsonProperty("parent_id") + String parentId, + /** 稳定菜单代码。 */ + @JsonProperty("menu_code") + String menuCode, + /** 菜单展示名称。 */ + @JsonProperty("menu_name") + String menuName, + /** 菜单类型。 */ + @JsonProperty("menu_type") + String menuType, + /** 前端路由路径。 */ + @JsonProperty("route_path") + String routePath, + /** 前端组件标识。 */ + @JsonProperty("component_key") + String componentKey, + /** 前端图标标识。 */ + @JsonProperty("icon_key") + String iconKey, + /** 菜单入口权限码。 */ + @JsonProperty("permission_code") + String permissionCode, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder, + /** 是否菜单可见。 */ + Boolean visible, + /** 菜单状态。 */ + @JsonProperty("menu_status") + String menuStatus, + /** 是否当前前端已知路由。 */ + @JsonProperty("known_route") + Boolean knownRoute, + /** 创建 UTC 时间。 */ + @JsonProperty("created_at") + OffsetDateTime createdAt, + /** 更新 UTC 时间。 */ + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java new file mode 100644 index 0000000..877396c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java @@ -0,0 +1,84 @@ +package cn.nianxx.thhotel.platform.navigation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest; +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest; +import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult; +import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理后台菜单 Controller。提供菜单查询入口。 + */ +@RestController +@RequestMapping("/api/admin/menus") +public class AdminMenuController { + + private final AdminAuthorizationService authorizationService; + private final AdminMenuManagementService menuManagementService; + + /** + * 注入管理鉴权和菜单管理服务。 + */ + public AdminMenuController( + AdminAuthorizationService authorizationService, + AdminMenuManagementService menuManagementService) { + this.authorizationService = authorizationService; + this.menuManagementService = menuManagementService; + } + + /** + * 分页查询菜单列表,需要菜单管理权限。 + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listMenus( + @RequestParam(required = false) String keyword, + @RequestParam(name = "menu_status", required = false) String menuStatus, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_MENU_MANAGE.name()); + return menuManagementService.queryMenus(keyword, menuStatus, pageNum, pageSize); + } + + /** + * 查询菜单详情,需要菜单管理权限。 + */ + @GetMapping(value = "/{menuId}", produces = MediaType.APPLICATION_JSON_VALUE) + public AdminMenuResult getMenu(@PathVariable String menuId) { + authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_MENU_MANAGE.name()); + return menuManagementService.getMenu(menuId); + } + + /** + * 新增菜单配置,需要菜单管理权限。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminMenuResult createMenu(@RequestBody(required = false) AdminMenuCreateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_MENU_MANAGE.name()); + return menuManagementService.createMenu(request, actor); + } + + /** + * 编辑菜单配置,需要菜单管理权限,菜单代码不允许修改。 + */ + @PutMapping(value = "/{menuId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AdminMenuResult updateMenu( + @PathVariable String menuId, + @RequestBody(required = false) AdminMenuUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.SYSTEM_MENU_MANAGE.name()); + return menuManagementService.updateMenu(menuId, request, actor); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java index b8338af..2bceee0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java @@ -1,9 +1,11 @@ package cn.nianxx.thhotel.platform.navigation.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus; import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; import cn.nianxx.thhotel.platform.navigation.mapper.PlatformMenuMapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -27,6 +29,37 @@ public class MybatisPlatformNavigationRepository implements PlatformNavigationRe .last("LIMIT 1"))); } + @Override + public Optional findMenuById(Long menuId) { + if (menuId == null) { + return Optional.empty(); + } + return Optional.ofNullable(menuMapper.selectById(menuId)); + } + + @Override + public List listAllMenus() { + return menuMapper.selectList(Wrappers.lambdaQuery() + .orderByAsc(PlatformMenuEntity::getSortOrder) + .orderByAsc(PlatformMenuEntity::getMenuCode)); + } + + @Override + public PlatformPageSnapshot queryMenus(String keyword, String menuStatus, int pageNum, int pageSize) { + Page page = menuMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(menuStatus != null && !menuStatus.isBlank(), PlatformMenuEntity::getMenuStatus, menuStatus) + .and(keyword != null && !keyword.isBlank(), wrapper -> wrapper + .like(PlatformMenuEntity::getMenuCode, keyword) + .or() + .like(PlatformMenuEntity::getMenuName, keyword) + .or() + .like(PlatformMenuEntity::getRoutePath, keyword)) + .orderByAsc(PlatformMenuEntity::getSortOrder) + .orderByAsc(PlatformMenuEntity::getMenuCode)); + return new PlatformPageSnapshot<>(page.getRecords(), page.getTotal(), pageNum, pageSize); + } + @Override public void insertMenu(PlatformMenuEntity menu) { menuMapper.insert(menu); diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java index d962207..040c1c2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.platform.navigation.repository; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; import java.util.List; import java.util.Optional; @@ -14,6 +15,21 @@ public interface PlatformNavigationRepository { */ Optional findMenuByCode(String menuCode); + /** + * 按菜单内部 ID 查询菜单定义。 + */ + Optional findMenuById(Long menuId); + + /** + * 查询全部菜单定义。 + */ + List listAllMenus(); + + /** + * 管理后台分页查询菜单。 + */ + PlatformPageSnapshot queryMenus(String keyword, String menuStatus, int pageNum, int pageSize); + /** * 新增菜单定义。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java new file mode 100644 index 0000000..2c5573d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.platform.navigation.service; + +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest; +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 管理后台菜单管理服务。第一版提供菜单查询。 + */ +public interface AdminMenuManagementService { + + /** + * 分页查询菜单列表。 + */ + PlatformPageResult queryMenus( + String keyword, + String menuStatus, + Integer pageNum, + Integer pageSize); + + /** + * 查询菜单详情。 + */ + AdminMenuResult getMenu(String menuId); + + /** + * 新增菜单定义。 + */ + AdminMenuResult createMenu(AdminMenuCreateRequest request, AuthenticatedUserContext actor); + + /** + * 编辑菜单定义,菜单代码不允许修改。 + */ + AdminMenuResult updateMenu(String menuId, AdminMenuUpdateRequest request, AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java new file mode 100644 index 0000000..1e3e227 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java @@ -0,0 +1,321 @@ +package cn.nianxx.thhotel.platform.navigation.service.impl; + +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.dto.PlatformPageSnapshot; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus; +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest; +import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest; +import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult; +import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; +import cn.nianxx.thhotel.platform.navigation.repository.PlatformNavigationRepository; +import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 管理后台菜单管理服务实现。菜单可见性只控制入口,不替代后端接口鉴权。 + */ +@Service +public class AdminMenuManagementServiceImpl implements AdminMenuManagementService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final Set KNOWN_ROUTES = Set.of( + "/reservation/orders", + "/reservation/tasks", + "/debug/eml-superagent", + "/source-messages", + "/system", + "/system/users", + "/system/roles", + "/system/menus", + "/system/hotels"); + + private final PlatformNavigationRepository navigationRepository; + private final PlatformAccessRepository accessRepository; + private final PlatformAdminAuditLogService auditLogService; + private final ObjectMapper objectMapper; + + /** + * 注入菜单仓储。 + */ + public AdminMenuManagementServiceImpl( + PlatformNavigationRepository navigationRepository, + PlatformAccessRepository accessRepository, + PlatformAdminAuditLogService auditLogService, + ObjectMapper objectMapper) { + this.navigationRepository = navigationRepository; + this.accessRepository = accessRepository; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; + } + + /** + * 分页查询菜单列表。 + */ + @Override + public PlatformPageResult queryMenus( + String keyword, + String menuStatus, + Integer pageNum, + Integer pageSize) { + int normalizedPageNum = normalizePageNum(pageNum); + int normalizedPageSize = normalizePageSize(pageSize); + PlatformPageSnapshot page = navigationRepository.queryMenus( + trimToNull(keyword), + trimToNull(menuStatus), + normalizedPageNum, + normalizedPageSize); + return new PlatformPageResult<>( + page.items().stream().map(this::toResult).toList(), + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 查询菜单详情。 + */ + @Override + public AdminMenuResult getMenu(String menuId) { + Long id = parseId(menuId, "菜单 ID 不合法。"); + return navigationRepository.findMenuById(id) + .map(this::toResult) + .orElseThrow(() -> notFound("菜单不存在。")); + } + + /** + * 新增菜单定义。菜单代码作为稳定业务键,一旦创建不提供修改入口。 + */ + @Override + @Transactional + public AdminMenuResult createMenu(AdminMenuCreateRequest request, AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("菜单创建请求不能为空。"); + } + String menuCode = requireCode(request.menuCode(), "菜单代码不能为空。"); + if (navigationRepository.findMenuByCode(menuCode).isPresent()) { + throw conflict("菜单代码已存在。"); + } + PlatformMenuEntity menu = new PlatformMenuEntity(); + applyMenuFields(menu, request.parentId(), request.menuName(), request.menuType(), request.routePath(), + request.componentKey(), request.iconKey(), request.permissionCode(), request.sortOrder(), + request.visible(), normalizeStatus(request.menuStatus(), PlatformMenuStatus.DISABLED.name()), true); + menu.setMenuCode(menuCode); + menu.setCreatedAt(nowUtc()); + menu.setUpdatedAt(nowUtc()); + navigationRepository.insertMenu(menu); + AdminMenuResult after = getMenu(stringId(menu.getId())); + audit(actor, "PLATFORM_MENU", stringId(menu.getId()), "CREATE_MENU", null, after); + return after; + } + + /** + * 编辑菜单定义。菜单代码不允许修改,避免历史审计和后端菜单矩阵失去稳定键。 + */ + @Override + @Transactional + public AdminMenuResult updateMenu(String menuId, AdminMenuUpdateRequest request, AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("菜单更新请求不能为空。"); + } + Long id = parseId(menuId, "菜单 ID 不合法。"); + PlatformMenuEntity menu = navigationRepository.findMenuById(id) + .orElseThrow(() -> notFound("菜单不存在。")); + AdminMenuResult before = toResult(menu); + applyMenuFields(menu, request.parentId(), request.menuName(), request.menuType(), request.routePath(), + request.componentKey(), request.iconKey(), request.permissionCode(), request.sortOrder(), + request.visible(), normalizeStatus(request.menuStatus(), menu.getMenuStatus()), false); + menu.setUpdatedAt(nowUtc()); + navigationRepository.updateMenu(menu); + AdminMenuResult after = getMenu(menuId); + audit(actor, "PLATFORM_MENU", menuId, "UPDATE_MENU", before, after); + return after; + } + + private AdminMenuResult toResult(PlatformMenuEntity menu) { + return new AdminMenuResult( + stringId(menu.getId()), + stringId(menu.getParentId()), + menu.getMenuCode(), + menu.getMenuName(), + menu.getMenuType(), + menu.getRoutePath(), + menu.getComponentKey(), + menu.getIconKey(), + menu.getPermissionCode(), + menu.getSortOrder(), + menu.getVisible(), + menu.getMenuStatus(), + menu.getRoutePath() == null || KNOWN_ROUTES.contains(menu.getRoutePath()), + UtcTimeFormatter.toUtcOffsetDateTime(menu.getCreatedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(menu.getUpdatedAt())); + } + + private int normalizePageNum(Integer pageNum) { + return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private Long parseId(String id, String message) { + try { + return Long.valueOf(id); + } catch (NumberFormatException exception) { + throw new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + } + + private AdminOperationException notFound(String message) { + return new AdminOperationException(HttpStatus.NOT_FOUND, "ADMIN_TARGET_NOT_FOUND", message); + } + + private AdminOperationException invalidRequest(String message) { + return new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message); + } + + private AdminOperationException conflict(String message) { + return new AdminOperationException(HttpStatus.CONFLICT, "ADMIN_CONFLICT", message); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } + + private void applyMenuFields( + PlatformMenuEntity menu, + String parentId, + String menuName, + String menuType, + String routePath, + String componentKey, + String iconKey, + String permissionCode, + Integer sortOrder, + Boolean visible, + String menuStatus, + boolean creating) { + menu.setParentId(parseNullableId(parentId, "父菜单 ID 不合法。")); + if (menu.getParentId() != null && navigationRepository.findMenuById(menu.getParentId()).isEmpty()) { + throw invalidRequest("父菜单不存在。"); + } + menu.setMenuName(requireText(menuName, "菜单名称不能为空。")); + menu.setMenuType(normalizeMenuType(menuType)); + menu.setRoutePath(trimToNull(routePath)); + menu.setComponentKey(trimToNull(componentKey)); + menu.setIconKey(trimToNull(iconKey)); + String normalizedPermissionCode = trimToNull(permissionCode); + if (normalizedPermissionCode != null + && accessRepository.findPermissionByCode(normalizedPermissionCode).isEmpty()) { + throw invalidRequest("菜单绑定的权限码不存在。"); + } + menu.setPermissionCode(normalizedPermissionCode); + menu.setSortOrder(sortOrder == null ? 0 : sortOrder); + menu.setVisible(visible == null ? (creating ? false : Boolean.TRUE.equals(menu.getVisible())) : visible); + validateMenuStatus(menuStatus); + menu.setMenuStatus(menuStatus); + } + + private Long parseNullableId(String id, String message) { + String normalized = trimToNull(id); + return normalized == null ? null : parseId(normalized, message); + } + + private String requireText(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + return normalized; + } + + private String requireCode(String value, String message) { + String normalized = requireText(value, message).toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z0-9_]{3,128}")) { + throw invalidRequest("菜单代码只能包含大写字母、数字和下划线。"); + } + return normalized; + } + + private String normalizeMenuType(String menuType) { + String normalized = trimToNull(menuType); + if (normalized == null) { + return "PAGE"; + } + normalized = normalized.toUpperCase(Locale.ROOT); + if (!Set.of("GROUP", "PAGE", "ACTION").contains(normalized)) { + throw invalidRequest("菜单类型不合法。"); + } + return normalized; + } + + private String normalizeStatus(String value, String defaultStatus) { + String normalized = trimToNull(value); + return normalized == null ? defaultStatus : normalized.toUpperCase(Locale.ROOT); + } + + private void validateMenuStatus(String status) { + if (!PlatformMenuStatus.ACTIVE.name().equals(status) && !PlatformMenuStatus.DISABLED.name().equals(status)) { + throw invalidRequest("菜单状态不合法。"); + } + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private void audit( + AuthenticatedUserContext actor, + String targetType, + String targetId, + String action, + Object before, + Object after) { + auditLogService.record(new PlatformAdminAuditLogDraft( + actor, + targetType, + targetId, + action, + toJson(before), + toJson(after))); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new AdminOperationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_AUDIT_SERIALIZE_FAILED", + "系统管理审计序列化失败。"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/AdminAuthorizationService.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/AdminAuthorizationService.java new file mode 100644 index 0000000..bd605cb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/AdminAuthorizationService.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.security.service; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 管理后台强制鉴权服务。管理接口通过该服务统一处理登录和权限边界。 + */ +public interface AdminAuthorizationService { + + /** + * 要求当前请求已登录,并返回当前用户上下文。 + */ + AuthenticatedUserContext requireLogin(); + + /** + * 要求当前请求用户拥有指定管理权限码。 + */ + AuthenticatedUserContext requirePermission(String permissionCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationException.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationException.java new file mode 100644 index 0000000..b0b3d6f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * 管理后台鉴权受控异常。ControllerAdvice 负责转换为稳定 HTTP 响应。 + */ +public class AdminAuthorizationException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public AdminAuthorizationException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationServiceImpl.java new file mode 100644 index 0000000..d073a9c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/AdminAuthorizationServiceImpl.java @@ -0,0 +1,76 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * 管理后台强制鉴权服务实现。只负责鉴权语义,不读取业务数据。 + */ +@Service +public class AdminAuthorizationServiceImpl implements AdminAuthorizationService { + + private final CurrentUserContextService currentUserContextService; + private final HttpServletRequest request; + + /** + * 注入当前用户上下文服务,复用可选 token 解析后的请求上下文。 + */ + public AdminAuthorizationServiceImpl( + CurrentUserContextService currentUserContextService, + HttpServletRequest request) { + this.currentUserContextService = currentUserContextService; + this.request = request; + } + + /** + * 要求当前请求已登录;没有有效 token 时返回管理后台专用 401。 + */ + @Override + public AuthenticatedUserContext requireLogin() { + return currentUserContextService.currentUser() + .orElseThrow(this::missingOrInvalidLogin); + } + + /** + * 要求当前请求用户拥有指定权限码;已登录但无权限时返回 403。 + */ + @Override + public AuthenticatedUserContext requirePermission(String permissionCode) { + AuthenticatedUserContext context = requireLogin(); + if (permissionCode == null || permissionCode.isBlank() + || !context.permissionCodes().contains(permissionCode)) { + throw new AdminAuthorizationException( + HttpStatus.FORBIDDEN, + "ADMIN_PERMISSION_DENIED", + "当前用户没有访问该系统管理能力的权限。"); + } + return context; + } + + private AdminAuthorizationException missingOrInvalidLogin() { + String authorizationHeader = request.getHeader("Authorization"); + if (hasBearerToken(authorizationHeader)) { + return new AdminAuthorizationException( + HttpStatus.UNAUTHORIZED, + "AUTH_SESSION_INVALID", + "登录已失效,请重新登录。"); + } + return new AdminAuthorizationException( + HttpStatus.UNAUTHORIZED, + "ADMIN_AUTH_REQUIRED", + "请先登录后再访问系统管理后台。"); + } + + private boolean hasBearerToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + return false; + } + String prefix = "Bearer "; + return authorizationHeader.regionMatches(true, 0, prefix, 0, prefix.length()) + && !authorizationHeader.substring(prefix.length()).trim().isBlank(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AdminControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AdminControllerAdvice.java new file mode 100644 index 0000000..cb04de5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AdminControllerAdvice.java @@ -0,0 +1,57 @@ +package cn.nianxx.thhotel.platform.system.control; + +import cn.nianxx.thhotel.platform.access.control.AdminPermissionController; +import cn.nianxx.thhotel.platform.access.control.AdminRoleController; +import cn.nianxx.thhotel.platform.audit.control.AdminAuditLogController; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformErrorResponse; +import cn.nianxx.thhotel.platform.hotel.control.AdminHotelController; +import cn.nianxx.thhotel.platform.identity.control.AdminUserController; +import cn.nianxx.thhotel.platform.navigation.control.AdminMenuController; +import cn.nianxx.thhotel.platform.security.service.impl.AdminAuthorizationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 管理后台接口统一异常处理。错误响应不暴露密码、token、secret 或内部堆栈。 + */ +@RestControllerAdvice(assignableTypes = { + AdminUserController.class, + AdminRoleController.class, + AdminPermissionController.class, + AdminAuditLogController.class, + AdminMenuController.class, + AdminHotelController.class +}) +public class AdminControllerAdvice { + + /** + * 处理管理后台登录和权限异常。 + */ + @ExceptionHandler(AdminAuthorizationException.class) + public ResponseEntity handleAuthorization(AdminAuthorizationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 处理管理后台业务受控异常。 + */ + @ExceptionHandler(AdminOperationException.class) + public ResponseEntity handleOperation(AdminOperationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 处理管理后台参数校验异常。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation() { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new PlatformErrorResponse("ADMIN_INVALID_REQUEST", "系统管理请求参数不合法。")); + } +} diff --git a/server/src/main/resources/db/migration/V15__create_platform_admin_audit_log.sql b/server/src/main/resources/db/migration/V15__create_platform_admin_audit_log.sql new file mode 100644 index 0000000..2645e9f --- /dev/null +++ b/server/src/main/resources/db/migration/V15__create_platform_admin_audit_log.sql @@ -0,0 +1,16 @@ +-- M006 系统管理后台:管理写操作审计表。 +CREATE TABLE platform_admin_audit_log ( + id BIGINT NOT NULL COMMENT '管理审计内部 ID', + actor_user_id BIGINT NOT NULL COMMENT '操作用户内部 ID', + actor_username VARCHAR(128) NOT NULL COMMENT '操作用户登录名摘要', + actor_display_name VARCHAR(128) NULL COMMENT '操作用户展示名摘要', + target_type VARCHAR(64) NOT NULL COMMENT '操作对象类型:USER、ROLE、MENU、HOTEL、USER_ROLE、USER_HOTEL 等', + target_id VARCHAR(128) NOT NULL COMMENT '操作对象稳定 ID,可能是内部 ID 或业务代码', + action VARCHAR(64) NOT NULL COMMENT '操作类型,例如 CREATE、UPDATE、ENABLE、DISABLE、RESET_PASSWORD、ASSIGN', + before_snapshot_json JSON NULL COMMENT '变更前摘要 JSON,不保存密码、token 或 secret', + after_snapshot_json JSON NULL COMMENT '变更后摘要 JSON,不保存密码、token 或 secret', + occurred_at DATETIME(6) NOT NULL COMMENT '操作发生 UTC 时间', + PRIMARY KEY (id), + KEY idx_platform_admin_audit_actor_time (actor_user_id, occurred_at), + KEY idx_platform_admin_audit_target_time (target_type, target_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='平台管理后台写操作审计表'; diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java index 8451bf3..025b6ce 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -81,6 +81,7 @@ class AuthControllerTest { "SYSTEM_ROLE_MANAGE", "SYSTEM_MENU_MANAGE", "HOTEL_MANAGE", + "SYSTEM_ADMIN_CONSOLE_ACCESS", "SYSTEM_DEBUG_EML_RUN"))) .andExpect(jsonPath("$.menus[0].menu_code").value("RESERVATION_ORDERS")) .andExpect(jsonPath("$.menus[0].route_path").value("/reservation/orders")) @@ -91,6 +92,9 @@ class AuthControllerTest { .andExpect(jsonPath("$.menus[2].menu_code").value("DEBUG_EML_SUPERAGENT")) .andExpect(jsonPath("$.menus[2].route_path").value("/debug/eml-superagent")) .andExpect(jsonPath("$.menus[2].icon_key").value("pi pi-upload")) + .andExpect(jsonPath("$.menus[3].menu_code").value("SYSTEM_SETTINGS")) + .andExpect(jsonPath("$.menus[3].route_path").value("/system")) + .andExpect(jsonPath("$.menus[3].icon_key").value("pi pi-cog")) .andReturn(); String token = tokenFrom(loginResult); @@ -100,7 +104,7 @@ class AuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.user.username").value("m003-admin")) .andExpect(jsonPath("$.default_hotel_id").value("HOTEL-TEST")) - .andExpect(jsonPath("$.menus[2].menu_code").value("DEBUG_EML_SUPERAGENT")); + .andExpect(jsonPath("$.menus[3].menu_code").value("SYSTEM_SETTINGS")); } @Test diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java new file mode 100644 index 0000000..57e7399 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java @@ -0,0 +1,418 @@ +package cn.nianxx.thhotel.platform.system.control; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleCode; +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m006_admin_readonly;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=m006-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=系统管理员", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店", + "auth.bootstrap.default-hotel-time-zone=Asia/Bangkok" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminReadonlyControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private PlatformIdentityRepository identityRepository; + @Autowired + private PlatformAccessRepository accessRepository; + @Autowired + private PlatformHotelRepository hotelRepository; + @Autowired + private AuthPasswordService passwordService; + + @BeforeEach + void ensureViewerUser() { + PlatformUserEntity user = identityRepository.findUserByUsername("m006-viewer") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("m006-viewer"); + created.setPasswordHash(passwordService.hash("Viewer@123456")); + created.setDisplayName("只读用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + PlatformRoleEntity viewerRole = accessRepository.findRoleByCode(PlatformRoleCode.RESERVATION_VIEWER.name()) + .orElseThrow(); + accessRepository.ensureUserRole(user.getId(), viewerRole.getId()); + hotelRepository.ensureUserHotel(user.getId(), "HOTEL-TEST", true); + } + + @Test + void shouldRejectAdminEndpointWhenTokenMissing() throws Exception { + mockMvc.perform(get("/api/admin/users") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("ADMIN_AUTH_REQUIRED")); + } + + @Test + void shouldRejectAdminEndpointWhenTokenInvalid() throws Exception { + mockMvc.perform(get("/api/admin/users") + .header("Authorization", "Bearer invalid-token") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_SESSION_INVALID")); + } + + @Test + void shouldAllowSystemAdminToReadAdminLists() throws Exception { + String token = tokenFrom(login("m006-admin", "Admin@123456")); + + mockMvc.perform(get("/api/admin/users") + .header("Authorization", "Bearer " + token) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items.length()", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.page.page_num").value(1)) + .andExpect(jsonPath("$.page.page_size").value(20)); + + mockMvc.perform(get("/api/admin/roles") + .header("Authorization", "Bearer " + token) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].role_code").value(notNullValue())); + + mockMvc.perform(get("/api/admin/permissions") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].permission_code").value(notNullValue())); + + mockMvc.perform(get("/api/admin/menus") + .header("Authorization", "Bearer " + token) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[?(@.menu_code=='SYSTEM_SETTINGS')]").exists()); + + mockMvc.perform(get("/api/admin/hotels") + .header("Authorization", "Bearer " + token) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].hotel_id").value("HOTEL-TEST")); + } + + @Test + void shouldRejectAdminEndpointWhenPermissionMissing() throws Exception { + String token = tokenFrom(login("m006-viewer", "Viewer@123456")); + + mockMvc.perform(get("/api/admin/users") + .header("Authorization", "Bearer " + token) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ADMIN_PERMISSION_DENIED")); + } + + @Test + void shouldCreateUserAssignHotelsResetPasswordAndRevokeSessionWhenDisabled() throws Exception { + String token = tokenFrom(login("m006-admin", "Admin@123456")); + PlatformRoleEntity viewerRole = accessRepository.findRoleByCode(PlatformRoleCode.RESERVATION_VIEWER.name()) + .orElseThrow(); + String username = "m006-user-" + System.nanoTime(); + + MvcResult createResult = mockMvc.perform(post("/api/admin/users") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "initial_password": "User@123456", + "display_name": "测试用户", + "user_status": "ACTIVE", + "role_ids": ["%s"], + "hotel_ids": ["HOTEL-TEST"], + "default_hotel_id": "HOTEL-TEST" + } + """.formatted(username, viewerRole.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$.roles[0].role_code").value(PlatformRoleCode.RESERVATION_VIEWER.name())) + .andExpect(jsonPath("$.hotels[0].hotel_id").value("HOTEL-TEST")) + .andReturn(); + String userId = objectMapper.readTree(createResult.getResponse().getContentAsString()).path("id").asText(); + String userToken = tokenFrom(login(username, "User@123456")); + + mockMvc.perform(post("/api/admin/users/{userId}/password-reset", userId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_id").value(userId)) + .andExpect(jsonPath("$.temporary_password").value(notNullValue())); + + mockMvc.perform(put("/api/admin/users/{userId}/roles", userId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "role_ids": [] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roles.length()").value(0)); + + mockMvc.perform(put("/api/admin/users/{userId}/hotels", userId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_ids": ["HOTEL-TEST"], + "default_hotel_id": "HOTEL-TEST" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hotels[0].default_hotel").value(true)); + + String disabledHotelId = "M006-DISABLED-" + System.nanoTime(); + mockMvc.perform(post("/api/admin/hotels") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id": "%s", + "hotel_name": "M006 禁用酒店", + "time_zone": "Asia/Bangkok", + "sort_order": 199 + } + """.formatted(disabledHotelId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hotel_status").value("DISABLED")); + + mockMvc.perform(put("/api/admin/users/{userId}/hotels", userId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_ids": ["HOTEL-TEST", "%s"], + "default_hotel_id": "HOTEL-TEST" + } + """.formatted(disabledHotelId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ADMIN_INVALID_REQUEST")); + + mockMvc.perform(put("/api/admin/users/{userId}", userId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "display_name": "测试用户已禁用", + "user_status": "DISABLED" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_status").value("DISABLED")); + + mockMvc.perform(get("/api/admin/audits") + .header("Authorization", "Bearer " + token) + .param("target_type", "PLATFORM_USER") + .param("target_id", userId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].target_type").value("PLATFORM_USER")); + + mockMvc.perform(get("/api/auth/me") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isUnauthorized()); + } + + @Test + void shouldManageCustomRoleAndRejectBuiltinRoleMutation() throws Exception { + String token = tokenFrom(login("m006-admin", "Admin@123456")); + String roleCode = "M006_CUSTOM_" + System.nanoTime(); + PlatformPermissionEntity permission = accessRepository.findPermissionByCode("SYSTEM_USER_MANAGE").orElseThrow(); + + MvcResult createResult = mockMvc.perform(post("/api/admin/roles") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "role_code": "%s", + "role_name": "M006 自定义角色", + "role_status": "ACTIVE" + } + """.formatted(roleCode))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role_code").value(roleCode)) + .andExpect(jsonPath("$.system_builtin").value(false)) + .andReturn(); + String roleId = objectMapper.readTree(createResult.getResponse().getContentAsString()).path("id").asText(); + + mockMvc.perform(put("/api/admin/roles/{roleId}/permissions", roleId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "permission_ids": ["%s"] + } + """.formatted(permission.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.permissions[0].permission_code").value("SYSTEM_USER_MANAGE")); + + PlatformRoleEntity builtinRole = accessRepository.findRoleByCode(PlatformRoleCode.SYSTEM_ADMIN.name()) + .orElseThrow(); + mockMvc.perform(put("/api/admin/roles/{roleId}", builtinRole.getId().toString()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "role_name": "不允许修改", + "role_status": "ACTIVE" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("ADMIN_CONFLICT")); + } + + @Test + void shouldCreateUnknownMenuAndKeepKnownRouteFlagFalse() throws Exception { + String token = tokenFrom(login("m006-admin", "Admin@123456")); + String menuCode = "M006_MENU_" + System.nanoTime(); + + MvcResult createResult = mockMvc.perform(post("/api/admin/menus") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "menu_code": "%s", + "menu_name": "未来菜单", + "menu_type": "PAGE", + "route_path": "/future/not-ready", + "permission_code": "SYSTEM_USER_MANAGE", + "visible": true, + "menu_status": "ACTIVE" + } + """.formatted(menuCode))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.menu_code").value(menuCode)) + .andExpect(jsonPath("$.known_route").value(false)) + .andReturn(); + String menuId = objectMapper.readTree(createResult.getResponse().getContentAsString()).path("id").asText(); + + mockMvc.perform(put("/api/admin/menus/{menuId}", menuId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "menu_name": "未来菜单更新", + "menu_type": "PAGE", + "route_path": "/system/users", + "permission_code": "SYSTEM_USER_MANAGE", + "visible": true, + "menu_status": "ACTIVE" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.menu_code").value(menuCode)) + .andExpect(jsonPath("$.known_route").value(true)); + } + + @Test + void shouldCreateDisabledHotelAndRejectBreakingSingleActiveHotelRule() throws Exception { + String token = tokenFrom(login("m006-admin", "Admin@123456")); + String hotelId = "M006-HOTEL-" + System.nanoTime(); + + mockMvc.perform(post("/api/admin/hotels") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id": "%s", + "hotel_name": "M006 测试酒店", + "time_zone": "Asia/Bangkok", + "sort_order": 99 + } + """.formatted(hotelId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hotel_id").value(hotelId)) + .andExpect(jsonPath("$.hotel_status").value("DISABLED")); + + mockMvc.perform(put("/api/admin/hotels/{hotelId}/status", hotelId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_status": "ACTIVE" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("ADMIN_CONFLICT")); + + mockMvc.perform(put("/api/admin/hotels/{hotelId}/status", "HOTEL-TEST") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_status": "DISABLED" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("ADMIN_CONFLICT")); + } + + private MvcResult login(String username, String password) throws Exception { + return mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))) + .andExpect(status().isOk()) + .andReturn(); + } + + private String tokenFrom(MvcResult result) throws Exception { + JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString()); + return json.path("access_token").asText(); + } +}