commit all
build-and-notify / Build Backend To GHCR (push) Failing after 1s
build-and-notify / Build Admin UI To GHCR (push) Failing after 0s
build-and-notify / Build Screen To GHCR (push) Failing after 0s
build-and-notify / Notify Sync Service (push) Has been skipped
build-and-notify / Publish Release Metadata (push) Has been skipped
CI / Lint (push) Failing after 0s
CI / Backend (push) Has been skipped
CI / Frontend (push) Has been skipped
Coverage / Backend JaCoCo (push) Failing after 4s
Gate Check / gate (push) Failing after 4s
Post Merge E2E / post_merge_e2e (push) Failing after 4s
Secret Scan / Gitleaks (push) Failing after 0s
Spec Quality Gate / spec-quality-check (push) Failing after 0s
SBOM / generate (push) Failing after 0s
Governance Drift / governance-drift (push) Failing after 1s
Nightly Regression / nightly_regression (push) Failing after 30s
CodeQL / CodeQL Capability Check (push) Has been cancelled
CodeQL / Analyze (java) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / CodeQL Skipped (push) Has been cancelled

This commit is contained in:
2026-03-08 08:02:33 +08:00
parent 8c69781e92
commit d5d9f8b79b
84 changed files with 3128 additions and 504 deletions
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -11,26 +11,26 @@ import type {
* 获取配置列表(分页)
*/
export function getConfigList(params?: ConfigQueryDTO): Promise<PageResult<SysConfigVO>> {
return request.get<PageResult<SysConfigVO>>('/api/configs', { params })
return request.get<PageResult<SysConfigVO>>('/api/v1/configs', { params })
}
/**
* 创建配置
*/
export function createConfig(data: ConfigCreateDTO): Promise<number> {
return request.post<number>('/api/configs', data)
return request.post<number>('/api/v1/configs', data)
}
/**
* 更新配置
*/
export function updateConfig(id: number, data: ConfigUpdateDTO): Promise<void> {
return request.put<void>(`/api/configs/${id}`, data)
return request.put<void>(`/api/v1/configs/${id}`, data)
}
/**
* 按 key 查询配置
*/
export function getConfigByKey(key: string): Promise<SysConfigVO> {
return request.get<SysConfigVO>(`/api/configs/key/${key}`)
return request.get<SysConfigVO>(`/api/v1/configs/key/${key}`)
}
+4 -4
View File
@@ -12,26 +12,26 @@ import type {
* 获取字典类型列表(分页)
*/
export function getDictList(params?: DictTypeQueryDTO): Promise<PageResult<SysDictTypeVO>> {
return request.get<PageResult<SysDictTypeVO>>('/api/dicts', { params })
return request.get<PageResult<SysDictTypeVO>>('/api/v1/dicts', { params })
}
/**
* 创建字典类型
*/
export function createDict(data: DictTypeCreateDTO): Promise<number> {
return request.post<number>('/api/dicts', data)
return request.post<number>('/api/v1/dicts', data)
}
/**
* 更新字典类型
*/
export function updateDict(id: number, data: DictTypeUpdateDTO): Promise<void> {
return request.put<void>(`/api/dicts/${id}`, data)
return request.put<void>(`/api/v1/dicts/${id}`, data)
}
/**
* 按类型查询字典数据列表
*/
export function getDictDataByType(type: string): Promise<SysDictDataVO[]> {
return request.get<SysDictDataVO[]>(`/api/dicts/type/${type}`)
return request.get<SysDictDataVO[]>(`/api/v1/dicts/type/${type}`)
}
+3 -3
View File
@@ -5,14 +5,14 @@ import type { PageResult, SysFileVO, FileQueryDTO } from '@ljwx/shared'
* 获取文件列表(分页)
*/
export function getFileList(params?: FileQueryDTO): Promise<PageResult<SysFileVO>> {
return request.get<PageResult<SysFileVO>>('/api/files', { params })
return request.get<PageResult<SysFileVO>>('/api/v1/files', { params })
}
/**
* 上传文件
*/
export function uploadFile(formData: FormData): Promise<SysFileVO> {
return request.post<SysFileVO>('/api/files/upload', formData, {
return request.post<SysFileVO>('/api/v1/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
@@ -21,7 +21,7 @@ export function uploadFile(formData: FormData): Promise<SysFileVO> {
* 删除文件
*/
export function deleteFile(id: number): Promise<void> {
return request.delete<void>(`/api/files/${id}`)
return request.delete<void>(`/api/v1/files/${id}`)
}
/**
+6 -6
View File
@@ -11,40 +11,40 @@ import type {
* 获取定时任务列表(分页)
*/
export function getJobList(params?: JobQueryDTO): Promise<PageResult<SysJobVO>> {
return request.get<PageResult<SysJobVO>>('/api/jobs', { params })
return request.get<PageResult<SysJobVO>>('/api/v1/jobs', { params })
}
/**
* 创建定时任务
*/
export function createJob(data: JobCreateDTO): Promise<number> {
return request.post<number>('/api/jobs', data)
return request.post<number>('/api/v1/jobs', data)
}
/**
* 更新定时任务
*/
export function updateJob(id: number, data: JobUpdateDTO): Promise<void> {
return request.put<void>(`/api/jobs/${id}`, data)
return request.put<void>(`/api/v1/jobs/${id}`, data)
}
/**
* 立即执行任务
*/
export function executeJob(id: number): Promise<void> {
return request.post<void>(`/api/jobs/${id}/execute`)
return request.post<void>(`/api/v1/jobs/${id}/run`)
}
/**
* 暂停任务
*/
export function pauseJob(id: number): Promise<void> {
return request.post<void>(`/api/jobs/${id}/pause`)
return request.post<void>(`/api/v1/jobs/${id}/pause`)
}
/**
* 恢复任务
*/
export function resumeJob(id: number): Promise<void> {
return request.post<void>(`/api/jobs/${id}/resume`)
return request.post<void>(`/api/v1/jobs/${id}/resume`)
}
+3 -3
View File
@@ -13,19 +13,19 @@ import type {
export function getOperationLogList(
params?: OperationLogQueryDTO,
): Promise<PageResult<OperationLogVO>> {
return request.get<PageResult<OperationLogVO>>('/api/logs/operation', { params })
return request.get<PageResult<OperationLogVO>>('/api/v1/logs/operation', { params })
}
/**
* 获取登录日志列表(分页)
*/
export function getLoginLogList(params?: LoginLogQueryDTO): Promise<PageResult<LoginLogVO>> {
return request.get<PageResult<LoginLogVO>>('/api/logs/login', { params })
return request.get<PageResult<LoginLogVO>>('/api/v1/logs/login', { params })
}
/**
* 导出日志
*/
export function exportLogs(): Promise<Blob> {
return request.post<Blob>('/api/logs/export', undefined, { responseType: 'blob' })
return request.post<Blob>('/api/v1/logs/export', undefined, { responseType: 'blob' })
}
+3 -3
View File
@@ -11,19 +11,19 @@ import type {
* 获取通知列表(分页)
*/
export function getNoticeList(params?: NoticeQueryDTO): Promise<PageResult<SysNoticeVO>> {
return request.get<PageResult<SysNoticeVO>>('/api/notices', { params })
return request.get<PageResult<SysNoticeVO>>('/api/v1/notices', { params })
}
/**
* 创建通知
*/
export function createNotice(data: NoticeCreateDTO): Promise<number> {
return request.post<number>('/api/notices', data)
return request.post<number>('/api/v1/notices', data)
}
/**
* 更新通知
*/
export function updateNotice(id: number, data: NoticeUpdateDTO): Promise<void> {
return request.put<void>(`/api/notices/${id}`, data)
return request.put<void>(`/api/v1/notices/${id}`, data)
}
+5 -5
View File
@@ -14,35 +14,35 @@ import type {
* 获取角色列表(分页)
*/
export function getRoleList(params?: RoleQueryDTO): Promise<PageResult<RoleVO>> {
return request.get<PageResult<RoleVO>>('/api/roles', { params })
return request.get<PageResult<RoleVO>>('/api/v1/roles', { params })
}
/**
* 创建角色
*/
export function createRole(data: RoleCreateDTO): Promise<number> {
return request.post<number>('/api/roles', data)
return request.post<number>('/api/v1/roles', data)
}
/**
* 更新角色
*/
export function updateRole(id: number, data: RoleUpdateDTO): Promise<void> {
return request.put<void>(`/api/roles/${id}`, data)
return request.put<void>(`/api/v1/roles/${id}`, data)
}
/**
* 删除角色
*/
export function deleteRole(id: number): Promise<void> {
return request.delete<void>(`/api/roles/${id}`)
return request.delete<void>(`/api/v1/roles/${id}`)
}
/**
* 获取权限列表
*/
export function getPermissionList(): Promise<PermissionVO[]> {
return request.get<PermissionVO[]>('/api/permissions')
return request.get<PermissionVO[]>('/api/v1/permissions')
}
/**
+3 -4
View File
@@ -13,21 +13,21 @@ import type {
* 获取租户列表(分页)
*/
export function getTenantList(params?: TenantQueryDTO): Promise<PageResult<TenantVO>> {
return request.get<PageResult<TenantVO>>('/api/tenants', { params })
return request.get<PageResult<TenantVO>>('/api/v1/tenants', { params })
}
/**
* 创建租户
*/
export function createTenant(data: TenantCreateDTO): Promise<number> {
return request.post<number>('/api/tenants', data)
return request.post<number>('/api/v1/tenants', data)
}
/**
* 更新租户
*/
export function updateTenant(id: number, data: TenantUpdateDTO): Promise<void> {
return request.put<void>(`/api/tenants/${id}`, data)
return request.put<void>(`/api/v1/tenants/${id}`, data)
}
/**
@@ -57,4 +57,3 @@ export function cancelTenant(id: number, data: TenantCancelDTO): Promise<void> {
export function initializeTenant(id: number): Promise<void> {
return request.post<void>(`/api/v1/tenants/${id}/initialize`)
}
+4 -4
View File
@@ -11,26 +11,26 @@ import type {
* 获取用户列表(分页)
*/
export function getUserList(params?: UserQueryDTO): Promise<PageResult<UserVO>> {
return request.get<PageResult<UserVO>>('/api/users', { params })
return request.get<PageResult<UserVO>>('/api/v1/users', { params })
}
/**
* 创建用户
*/
export function createUser(data: UserCreateDTO): Promise<number> {
return request.post<number>('/api/users', data)
return request.post<number>('/api/v1/users', data)
}
/**
* 更新用户
*/
export function updateUser(id: number, data: UserUpdateDTO): Promise<void> {
return request.put<void>(`/api/users/${id}`, data)
return request.put<void>(`/api/v1/users/${id}`, data)
}
/**
* 删除用户
*/
export function deleteUser(id: number): Promise<void> {
return request.delete<void>(`/api/users/${id}`)
return request.delete<void>(`/api/v1/users/${id}`)
}
+82 -36
View File
@@ -1,44 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const defineStore: typeof import('pinia')['defineStore']
const storeToRefs: typeof import('pinia')['storeToRefs']
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+85 -4
View File
@@ -1,29 +1,36 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* Prettier disabled */
/* TypeScript disabled */
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -42,19 +49,93 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
HelpButton: typeof import('./components/HelpButton.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
// For TSX support
declare global {
const ElAlert: typeof import('element-plus/es')['ElAlert']
const ElAside: typeof import('element-plus/es')['ElAside']
const ElAvatar: typeof import('element-plus/es')['ElAvatar']
const ElBadge: typeof import('element-plus/es')['ElBadge']
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
const ElCol: typeof import('element-plus/es')['ElCol']
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
const ElContainer: typeof import('element-plus/es')['ElContainer']
const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDivider: typeof import('element-plus/es')['ElDivider']
const ElDrawer: typeof import('element-plus/es')['ElDrawer']
const ElDropdown: typeof import('element-plus/es')['ElDropdown']
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
const ElEmpty: typeof import('element-plus/es')['ElEmpty']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElHeader: typeof import('element-plus/es')['ElHeader']
const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElImage: typeof import('element-plus/es')['ElImage']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
const ElLink: typeof import('element-plus/es')['ElLink']
const ElMain: typeof import('element-plus/es')['ElMain']
const ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption']
const ElPagination: typeof import('element-plus/es')['ElPagination']
const ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
const ElProgress: typeof import('element-plus/es')['ElProgress']
const ElRadio: typeof import('element-plus/es')['ElRadio']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElRow: typeof import('element-plus/es')['ElRow']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSlider: typeof import('element-plus/es')['ElSlider']
const ElSpace: typeof import('element-plus/es')['ElSpace']
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
const ElTabPane: typeof import('element-plus/es')['ElTabPane']
const ElTabs: typeof import('element-plus/es')['ElTabs']
const ElTag: typeof import('element-plus/es')['ElTag']
const ElText: typeof import('element-plus/es')['ElText']
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
const ElTree: typeof import('element-plus/es')['ElTree']
const ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
const ElUpload: typeof import('element-plus/es')['ElUpload']
const HelpButton: typeof import('./components/HelpButton.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
}
@@ -2,31 +2,136 @@
import { computed } from 'vue'
import type { Component } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Grid, Odometer } from '@element-plus/icons-vue'
import type { RouteRecordNormalized } from 'vue-router'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { Grid, Menu as MenuIcon } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
import { usePermissionStore } from '@/stores/permission'
const appStore = useAppStore()
const permissionStore = usePermissionStore()
const router = useRouter()
const route = useRoute()
const isCollapsed = computed(() => appStore.sidebarCollapsed)
interface MenuItem {
name: string
path: string
icon: Component
interface SidebarItem {
index: string
path?: string
title: string
icon: Component
children?: SidebarItem[]
}
const menuItems: MenuItem[] = [
{ name: 'Dashboard', path: '/dashboard', icon: Odometer, title: '仪表盘' },
]
const groupMeta: Record<string, { title: string; icon: Component }> = {
system: { title: '系统管理', icon: ElementPlusIconsVue.Setting },
monitor: { title: '系统监控', icon: ElementPlusIconsVue.Monitor },
ops: { title: '运营中心', icon: ElementPlusIconsVue.DataAnalysis },
report: { title: '报表中心', icon: ElementPlusIconsVue.PieChart },
workflow: { title: '工作流', icon: ElementPlusIconsVue.Connection },
form: { title: '表单中心', icon: ElementPlusIconsVue.EditPen },
message: { title: '消息中心', icon: ElementPlusIconsVue.ChatDotRound },
billing: { title: '计费中心', icon: ElementPlusIconsVue.Coin },
tenant: { title: '租户中心', icon: ElementPlusIconsVue.OfficeBuilding },
}
function normalizePath(path: string): string {
if (!path) {
return '/'
}
return path.startsWith('/') ? path : `/${path}`
}
function getRouteTitle(routeRecord: RouteRecordNormalized): string {
return String(routeRecord.meta.title || routeRecord.name || routeRecord.path)
}
function resolveIcon(iconName: unknown, fallback: Component = MenuIcon): Component {
if (typeof iconName === 'string' && iconName in ElementPlusIconsVue) {
return ElementPlusIconsVue[iconName as keyof typeof ElementPlusIconsVue]
}
return fallback
}
function getCandidateRoutes(): RouteRecordNormalized[] {
if (permissionStore.accessibleRoutes.length > 0) {
return permissionStore.accessibleRoutes as RouteRecordNormalized[]
}
return router.getRoutes().filter((routeRecord) => (
routeRecord.meta.requiresAuth === true &&
routeRecord.path !== '/' &&
!routeRecord.redirect
))
}
const menuItems = computed<SidebarItem[]>(() => {
const grouped = new Map<string, SidebarItem>()
const topLevelItems: SidebarItem[] = []
for (const routeRecord of getCandidateRoutes()) {
if (routeRecord.meta.hidden) {
continue
}
const absolutePath = normalizePath(routeRecord.path)
const segments = absolutePath.replace(/^\/+/, '').split('/').filter(Boolean)
if (segments.length <= 1) {
topLevelItems.push({
index: absolutePath,
path: absolutePath,
title: getRouteTitle(routeRecord),
icon: resolveIcon(routeRecord.meta.icon, MenuIcon),
})
continue
}
const groupKey = segments[0]
const meta = groupMeta[groupKey] ?? {
title: groupKey,
icon: resolveIcon(routeRecord.meta.icon, MenuIcon),
}
const groupIndex = `group:${groupKey}`
if (!grouped.has(groupIndex)) {
grouped.set(groupIndex, {
index: groupIndex,
title: meta.title,
icon: meta.icon,
children: [],
})
}
grouped.get(groupIndex)!.children!.push({
index: absolutePath,
path: absolutePath,
title: getRouteTitle(routeRecord),
icon: resolveIcon(routeRecord.meta.icon, meta.icon),
})
}
return [...topLevelItems, ...grouped.values()]
})
const defaultOpeneds = computed(() => {
const openGroups = new Set<string>()
for (const item of menuItems.value) {
if (item.children?.some((child) => child.path && isActive(child.path))) {
openGroups.add(item.index)
}
}
return [...openGroups]
})
function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/')
return route.path === path || route.path.startsWith(`${path}/`)
}
function navigateTo(path: string): void {
function navigateTo(path?: string): void {
if (!path || route.path === path) {
return
}
router.push(path)
}
</script>
@@ -44,23 +149,45 @@ function navigateTo(path: string): void {
<el-menu
:collapse="isCollapsed"
:default-active="route.path"
:default-openeds="defaultOpeneds"
background-color="#001529"
text-color="rgba(255,255,255,0.65)"
active-text-color="#fff"
class="sidebar-menu"
>
<el-menu-item
v-for="item in menuItems"
:key="item.name"
:index="item.path"
@click="navigateTo(item.path)"
:class="{ 'is-active': isActive(item.path) }"
>
<el-icon>
<component :is="item.icon" />
</el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
<template v-for="item in menuItems" :key="item.index">
<el-sub-menu v-if="item.children?.length" :index="item.index">
<template #title>
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.title }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.index"
:index="child.index"
:class="{ 'is-active': child.path ? isActive(child.path) : false }"
@click="navigateTo(child.path)"
>
<el-icon>
<component :is="child.icon" />
</el-icon>
<template #title>{{ child.title }}</template>
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else
:index="item.index"
:class="{ 'is-active': item.path ? isActive(item.path) : false }"
@click="navigateTo(item.path)"
>
<el-icon>
<component :is="item.icon" />
</el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</template>
</el-menu>
</div>
</template>
+274
View File
@@ -43,6 +43,28 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'ops/dashboard',
name: 'OpsDashboard',
component: () => import('@/views/ops/dashboard/index.vue'),
meta: {
title: '运营看板',
icon: 'DataAnalysis',
requiresAuth: true,
authority: 'system:ops:dashboard',
},
},
{
path: 'billing/usage',
name: 'BillingUsage',
component: () => import('@/views/billing/usage/index.vue'),
meta: {
title: '用量计费',
icon: 'Coin',
requiresAuth: true,
authority: 'system:billing:list',
},
},
{
path: 'system/user',
name: 'SystemUser',
@@ -73,6 +95,26 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'system/tenant/lifecycle',
name: 'SystemTenantLifecycle',
component: () => import('@/views/system/tenant/lifecycle.vue'),
meta: {
title: '租户生命周期',
icon: 'Clock',
requiresAuth: true,
},
},
{
path: 'system/tenant-package',
name: 'SystemTenantPackage',
component: () => import('@/views/system/tenantPackage/index.vue'),
meta: {
title: '租户套餐',
icon: 'Box',
requiresAuth: true,
},
},
{
path: 'system/dict',
name: 'SystemDict',
@@ -93,6 +135,47 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'system/ai-config',
name: 'SystemAiConfig',
component: () => import('@/views/system/ai-config/index.vue'),
meta: {
title: 'AI 配置',
icon: 'MagicStick',
requiresAuth: true,
},
},
{
path: 'system/custom-field',
name: 'SystemCustomField',
component: () => import('@/views/system/custom-field/index.vue'),
meta: {
title: '自定义字段',
icon: 'EditPen',
requiresAuth: true,
},
},
{
path: 'system/help',
name: 'SystemHelp',
component: () => import('@/views/system/help/index.vue'),
meta: {
title: '帮助文档',
icon: 'QuestionFilled',
requiresAuth: true,
authority: 'system:help:list',
},
},
{
path: 'system/import-export',
name: 'SystemImportExport',
component: () => import('@/views/system/import-export/index.vue'),
meta: {
title: '导入导出',
icon: 'UploadFilled',
requiresAuth: true,
},
},
{
path: 'system/job',
name: 'SystemJob',
@@ -123,6 +206,16 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'system/post',
name: 'SystemPost',
component: () => import('@/views/system/post/index.vue'),
meta: {
title: '岗位管理',
icon: 'Suitcase',
requiresAuth: true,
},
},
{
path: 'monitor/operlog',
name: 'MonitorOperlog',
@@ -143,6 +236,27 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'monitor/server',
name: 'MonitorServer',
component: () => import('@/views/monitor/server/index.vue'),
meta: {
title: '服务监控',
icon: 'Cpu',
requiresAuth: true,
authority: 'system:monitor:server',
},
},
{
path: 'monitor/ai-assistant',
name: 'MonitorAiAssistant',
component: () => import('@/views/monitor/ai-assistant/index.vue'),
meta: {
title: 'AI 助手',
icon: 'ChatDotRound',
requiresAuth: true,
},
},
{
path: 'system/dept',
name: 'SystemDept',
@@ -163,6 +277,26 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'system/open-api/app',
name: 'SystemOpenApiApp',
component: () => import('@/views/system/open-api/app/index.vue'),
meta: {
title: '开放应用',
icon: 'Link',
requiresAuth: true,
},
},
{
path: 'system/open-api/secret',
name: 'SystemOpenApiSecret',
component: () => import('@/views/system/open-api/secret/index.vue'),
meta: {
title: '应用密钥',
icon: 'Key',
requiresAuth: true,
},
},
{
path: 'system/profile',
name: 'SystemProfile',
@@ -173,6 +307,46 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
{
path: 'system/webhook',
name: 'SystemWebhook',
component: () => import('@/views/system/webhook/index.vue'),
meta: {
title: 'Webhook',
icon: 'Connection',
requiresAuth: true,
},
},
{
path: 'system/task-log',
name: 'SystemTaskLog',
component: () => import('@/views/system/task-log/index.vue'),
meta: {
title: '任务执行日志',
icon: 'List',
requiresAuth: true,
},
},
{
path: 'system/message/inbox',
name: 'SystemMessageInbox',
component: () => import('@/views/system/message/inbox.vue'),
meta: {
title: '站内信收件箱',
icon: 'MessageBox',
requiresAuth: true,
},
},
{
path: 'system/message/records',
name: 'SystemMessageRecords',
component: () => import('@/views/system/message/records.vue'),
meta: {
title: '消息发送记录',
icon: 'Tickets',
requiresAuth: true,
},
},
{
path: 'monitor/online-user',
name: 'MonitorOnlineUser',
@@ -194,6 +368,106 @@ const routes: RouteRecordRaw[] = [
authority: 'system:audit:list',
},
},
{
path: 'form/designer',
name: 'FormDesigner',
component: () => import('@/views/form/designer/index.vue'),
meta: {
title: '表单设计器',
icon: 'EditPen',
requiresAuth: true,
},
},
{
path: 'form/data',
name: 'FormData',
component: () => import('@/views/form/data/index.vue'),
meta: {
title: '表单数据',
icon: 'Document',
requiresAuth: true,
},
},
{
path: 'message/template',
name: 'MessageTemplate',
component: () => import('@/views/message/template/index.vue'),
meta: {
title: '消息模板',
icon: 'ChatLineSquare',
requiresAuth: true,
},
},
{
path: 'message/subscription',
name: 'MessageSubscription',
component: () => import('@/views/message/subscription/index.vue'),
meta: {
title: '消息订阅',
icon: 'BellFilled',
requiresAuth: true,
},
},
{
path: 'workflow/definition',
name: 'WorkflowDefinition',
component: () => import('@/views/workflow/definition/index.vue'),
meta: {
title: '流程定义',
icon: 'Connection',
requiresAuth: true,
},
},
{
path: 'workflow/instance',
name: 'WorkflowInstance',
component: () => import('@/views/workflow/instance/index.vue'),
meta: {
title: '流程实例',
icon: 'Files',
requiresAuth: true,
},
},
{
path: 'workflow/task',
name: 'WorkflowTask',
component: () => import('@/views/workflow/task/index.vue'),
meta: {
title: '流程任务',
icon: 'Checked',
requiresAuth: true,
},
},
{
path: 'report/designer',
name: 'ReportDesigner',
component: () => import('@/views/report/designer/index.vue'),
meta: {
title: '报表设计',
icon: 'Histogram',
requiresAuth: true,
},
},
{
path: 'report/preview',
name: 'ReportPreview',
component: () => import('@/views/report/preview/index.vue'),
meta: {
title: '报表预览',
icon: 'View',
requiresAuth: true,
},
},
{
path: 'tenant/brand',
name: 'TenantBrand',
component: () => import('@/views/tenant/brand/index.vue'),
meta: {
title: '租户品牌',
icon: 'Brush',
requiresAuth: true,
},
},
],
},
{
+31 -26
View File
@@ -1,35 +1,40 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8080'
return {
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
},
},
}
})
@@ -1,10 +1,14 @@
package com.ljwx.platform.app.ai.tool;
import com.ljwx.platform.app.domain.entity.SysJob;
import com.ljwx.platform.app.domain.entity.TaskExecutionLog;
import com.ljwx.platform.app.infra.mapper.SysJobMapper;
import com.ljwx.platform.app.infra.mapper.TaskExecutionLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -18,6 +22,9 @@ import java.util.Map;
@RequiredArgsConstructor
public class JobQueryTool {
private final SysJobMapper sysJobMapper;
private final TaskExecutionLogMapper taskExecutionLogMapper;
/**
* 列出定时任务及状态
* 权限约束:仅 system:job:list
@@ -25,12 +32,20 @@ public class JobQueryTool {
* @return 定时任务列表
*/
public Map<String, Object> listScheduledJobs() {
Map<String, Object> result = new HashMap<>();
// 简化实现:返回占位数据
// 实际应调用 jobMapper 查询
result.put("total", 0);
result.put("jobs", List.of());
return result;
List<SysJob> jobs = sysJobMapper.selectList(new SysJob());
List<Map<String, Object>> jobSummaries = jobs.stream()
.map(job -> Map.<String, Object>of(
"id", job.getId(),
"jobName", job.getJobName(),
"jobGroup", job.getJobGroup() == null ? "" : job.getJobGroup(),
"cronExpression", job.getCronExpression() == null ? "" : job.getCronExpression(),
"status", job.getStatus() == null ? 0 : job.getStatus(),
"description", job.getDescription() == null ? "" : job.getDescription()))
.toList();
return Map.of(
"total", jobSummaries.size(),
"jobs", jobSummaries
);
}
/**
@@ -42,11 +57,38 @@ public class JobQueryTool {
* @return 任务执行日志
*/
public Map<String, Object> getJobLogs(Long jobId, Integer limit) {
Map<String, Object> result = new HashMap<>();
// 简化实现:返回占位数据
// 实际应调用 jobLogMapper 查询
result.put("total", 0);
result.put("logs", List.of());
return result;
if (jobId == null) {
return Map.of("total", 0, "logs", List.of());
}
SysJob job = sysJobMapper.selectById(jobId);
if (job == null) {
return Map.of("total", 0, "logs", List.of(), "message", "任务不存在");
}
TaskExecutionLog query = new TaskExecutionLog();
query.setTaskName(job.getJobName());
query.setTaskGroup(job.getJobGroup());
List<TaskExecutionLog> logs = taskExecutionLogMapper.selectList(query);
int max = limit == null || limit <= 0 ? 10 : limit;
List<Map<String, Object>> logSummaries = new ArrayList<>();
for (TaskExecutionLog log : logs.stream().limit(max).toList()) {
logSummaries.add(Map.of(
"id", log.getId(),
"taskName", log.getTaskName(),
"taskGroup", log.getTaskGroup() == null ? "" : log.getTaskGroup(),
"status", log.getStatus() == null ? "" : log.getStatus(),
"startTime", log.getStartTime(),
"endTime", log.getEndTime(),
"duration", log.getDuration() == null ? 0 : log.getDuration(),
"errorMessage", log.getErrorMessage() == null ? "" : log.getErrorMessage()
));
}
return Map.of(
"total", logs.size(),
"logs", logSummaries
);
}
}
@@ -2,10 +2,12 @@ package com.ljwx.platform.app.ai.tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -19,6 +21,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class LogQueryTool {
private final JdbcTemplate jdbcTemplate;
/**
* 查询操作日志
* 权限约束:仅 system:operlog:list
@@ -32,12 +36,56 @@ public class LogQueryTool {
*/
public Map<String, Object> queryOperationLogs(LocalDateTime startTime, LocalDateTime endTime,
Long userId, String module, Integer limit) {
Map<String, Object> result = new HashMap<>();
// 简化实现:返回占位数据
// 实际应调用 operationLogMapper 查询
result.put("total", 0);
result.put("logs", List.of());
return result;
StringBuilder where = new StringBuilder(" WHERE deleted = FALSE");
List<Object> params = new ArrayList<>();
if (startTime != null) {
where.append(" AND created_time >= ?");
params.add(Timestamp.valueOf(startTime));
}
if (endTime != null) {
where.append(" AND created_time <= ?");
params.add(Timestamp.valueOf(endTime));
}
if (userId != null) {
where.append(" AND operator_id = ?");
params.add(userId);
}
if (module != null && !module.isBlank()) {
where.append(" AND title ILIKE ?");
params.add("%" + module.trim() + "%");
}
Long total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_operation_log" + where,
params.toArray(),
Long.class
);
int max = limit == null || limit <= 0 ? 10 : limit;
List<Object> logParams = new ArrayList<>(params);
logParams.add(max);
List<Map<String, Object>> logs = jdbcTemplate.query(
"SELECT id, title, operator_id, operator_name, status, request_method, request_url, cost_time, created_time " +
"FROM sys_operation_log" + where + " ORDER BY created_time DESC LIMIT ?",
logParams.toArray(),
(rs, rowNum) -> Map.<String, Object>of(
"id", rs.getLong("id"),
"title", rs.getString("title") == null ? "" : rs.getString("title"),
"operatorId", rs.getLong("operator_id"),
"operatorName", rs.getString("operator_name") == null ? "" : rs.getString("operator_name"),
"status", rs.getInt("status"),
"requestMethod", rs.getString("request_method") == null ? "" : rs.getString("request_method"),
"requestUrl", rs.getString("request_url") == null ? "" : rs.getString("request_url"),
"costTime", rs.getLong("cost_time"),
"createdTime", rs.getTimestamp("created_time").toLocalDateTime()
)
);
return Map.of(
"total", total == null ? 0L : total,
"logs", logs
);
}
/**
@@ -52,11 +100,49 @@ public class LogQueryTool {
*/
public Map<String, Object> queryLoginLogs(LocalDateTime startTime, LocalDateTime endTime,
Long userId, Integer limit) {
Map<String, Object> result = new HashMap<>();
// 简化实现:返回占位数据
// 实际应调用 loginLogMapper 查询
result.put("total", 0);
result.put("logs", List.of());
return result;
String fromClause = " FROM sys_login_log l LEFT JOIN sys_user u ON u.username = l.username AND u.deleted = FALSE";
StringBuilder where = new StringBuilder(" WHERE l.deleted = FALSE");
List<Object> params = new ArrayList<>();
if (startTime != null) {
where.append(" AND l.login_time >= ?");
params.add(Timestamp.valueOf(startTime));
}
if (endTime != null) {
where.append(" AND l.login_time <= ?");
params.add(Timestamp.valueOf(endTime));
}
if (userId != null) {
where.append(" AND u.id = ?");
params.add(userId);
}
Long total = jdbcTemplate.queryForObject(
"SELECT COUNT(*)" + fromClause + where,
params.toArray(),
Long.class
);
int max = limit == null || limit <= 0 ? 10 : limit;
List<Object> logParams = new ArrayList<>(params);
logParams.add(max);
List<Map<String, Object>> logs = jdbcTemplate.query(
"SELECT l.id, l.username, l.status, l.message, l.ip_address, l.login_time" +
fromClause + where + " ORDER BY l.login_time DESC LIMIT ?",
logParams.toArray(),
(rs, rowNum) -> Map.<String, Object>of(
"id", rs.getLong("id"),
"username", rs.getString("username") == null ? "" : rs.getString("username"),
"status", rs.getInt("status"),
"message", rs.getString("message") == null ? "" : rs.getString("message"),
"ipAddress", rs.getString("ip_address") == null ? "" : rs.getString("ip_address"),
"loginTime", rs.getTimestamp("login_time").toLocalDateTime()
)
);
return Map.of(
"total", total == null ? 0L : total,
"logs", logs
);
}
}
@@ -1,7 +1,11 @@
package com.ljwx.platform.app.ai.tool;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
@@ -20,6 +24,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class MonitorTool {
private final CacheManager cacheManager;
/**
* 获取服务器状态
* 权限约束:仅 system:monitor:server
@@ -69,13 +75,30 @@ public class MonitorTool {
*/
public Map<String, Object> getCacheStats() {
Map<String, Object> stats = new HashMap<>();
// 简化实现:返回占位数据
// 实际应从 CacheManager 获取 Caffeine 缓存统计
stats.put("hitRate", 0.85);
stats.put("missRate", 0.15);
stats.put("totalRequests", 10000);
stats.put("cacheSize", 500);
long totalRequests = 0L;
long totalHits = 0L;
long totalMisses = 0L;
long cacheSize = 0L;
for (String cacheName : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheName);
if (cache instanceof CaffeineCache caffeineCache) {
cacheSize += caffeineCache.getNativeCache().estimatedSize();
CacheStats cacheStats = caffeineCache.getNativeCache().stats();
totalRequests += cacheStats.requestCount();
totalHits += cacheStats.hitCount();
totalMisses += cacheStats.missCount();
}
}
double hitRate = totalRequests == 0 ? 0D : (double) totalHits / totalRequests;
double missRate = totalRequests == 0 ? 0D : (double) totalMisses / totalRequests;
stats.put("hitRate", hitRate);
stats.put("missRate", missRate);
stats.put("totalRequests", totalRequests);
stats.put("cacheSize", cacheSize);
stats.put("cacheCount", cacheManager.getCacheNames().size());
return stats;
}
}
@@ -1,10 +1,11 @@
package com.ljwx.platform.app.ai.tool;
import com.ljwx.platform.app.appservice.OnlineUserAppService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
@@ -17,6 +18,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class OnlineUserTool {
private final OnlineUserAppService onlineUserAppService;
/**
* 获取当前在线用户数
* 权限约束:仅 system:online:list
@@ -24,11 +27,11 @@ public class OnlineUserTool {
* @return 在线用户数
*/
public Map<String, Object> getOnlineUserCount() {
Map<String, Object> result = new HashMap<>();
// 简化实现:返回占位数据
// 实际应从 Redis 或 Session 管理器获取
result.put("onlineCount", 0);
result.put("timestamp", System.currentTimeMillis());
return result;
List<Map<String, String>> onlineUsers = onlineUserAppService.listOnlineUsers();
return Map.of(
"onlineCount", onlineUsers.size(),
"users", onlineUsers.stream().limit(10).toList(),
"timestamp", System.currentTimeMillis()
);
}
}
@@ -44,6 +44,8 @@ public class AuthAppService {
private final CurrentTenantHolder tenantHolder;
private final TokenBlacklistService tokenBlacklistService;
private final LoginLockoutService loginLockoutService;
private final LoginLogAppService loginLogAppService;
private final OnlineUserAppService onlineUserAppService;
/**
* 用户登录:校验密码 → 加载权限 → 签发双 Token。
@@ -52,8 +54,19 @@ public class AuthAppService {
* selectByUsername 跨租户查询(LIMIT 1 取首条)。
*/
public LoginVO login(LoginDTO dto) {
return login(dto, null, null);
}
/**
* 用户登录:校验密码 → 加载权限 → 签发双 Token。
*
* <p>登录时无 Auth 上下文,TenantLineInterceptor 绕过,
* selectByUsername 跨租户查询(LIMIT 1 取首条)。
*/
public LoginVO login(LoginDTO dto, String clientIp, String userAgent) {
// 0. 检查账号是否被锁定
if (loginLockoutService.isLocked(dto.getUsername())) {
recordLogin(dto.getUsername(), clientIp, userAgent, false, "账号已锁定,请30分钟后重试");
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请30分钟后重试");
}
@@ -61,17 +74,20 @@ public class AuthAppService {
SysUser user = userMapper.selectByUsername(dto.getUsername());
if (user == null) {
loginLockoutService.recordFailure(dto.getUsername());
recordLogin(dto.getUsername(), clientIp, userAgent, false, "用户名或密码错误");
throw new BusinessException(ErrorCode.TOKEN_INVALID, "用户名或密码错误");
}
// 2. BCrypt 密码校验(日志不输出 password)
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
loginLockoutService.recordFailure(dto.getUsername());
recordLogin(dto.getUsername(), clientIp, userAgent, false, "用户名或密码错误");
throw new BusinessException(ErrorCode.TOKEN_INVALID, "用户名或密码错误");
}
// 3. 账号状态校验
if (user.getStatus() == null || user.getStatus() != 1) {
recordLogin(dto.getUsername(), clientIp, userAgent, false, "账号已禁用");
throw new BusinessException(ErrorCode.TOKEN_INVALID, "账号已禁用");
}
@@ -96,6 +112,8 @@ public class AuthAppService {
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(jwtProperties.getAccessTokenExpiration());
vo.setUserInfo(userInfo);
registerOnlineToken(accessToken, user.getUsername());
recordLogin(dto.getUsername(), clientIp, userAgent, true, "登录成功");
return vo;
}
@@ -125,6 +143,7 @@ public class AuthAppService {
vo.setAccessToken(newAccess);
vo.setRefreshToken(newRefresh);
vo.setExpiresIn(jwtProperties.getAccessTokenExpiration());
registerOnlineToken(newAccess, username);
return vo;
}
@@ -172,9 +191,23 @@ public class AuthAppService {
long remainingSeconds =
(claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
tokenBlacklistService.addToBlacklist(jti, remainingSeconds);
onlineUserAppService.remove(jti);
}
}
private void registerOnlineToken(String token, String username) {
Claims claims = jwtTokenProvider.parseToken(token);
String jti = jwtTokenProvider.getJti(claims);
if (jti != null) {
onlineUserAppService.register(jti, username);
}
}
private void recordLogin(String username, String clientIp, String userAgent,
boolean success, String message) {
loginLogAppService.recordLogin(username, clientIp, userAgent, success ? 1 : 0, message);
}
private UserInfoVO buildUserInfo(SysUser user, List<String> authorities, List<String> roles) {
UserInfoVO vo = new UserInfoVO();
vo.setId(user.getId());
@@ -6,7 +6,9 @@ import com.ljwx.platform.app.domain.dto.ConfigUpdateDTO;
import com.ljwx.platform.app.domain.entity.SysConfig;
import com.ljwx.platform.app.infra.mapper.SysConfigMapper;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
@@ -39,6 +41,14 @@ public class ConfigAppService {
return new PageResult<>(records, total);
}
public SysConfig getConfigById(Long id) {
SysConfig config = configMapper.selectById(id);
if (config == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "配置不存在");
}
return config;
}
/**
* 按键名查询配置值,使用 Caffeine 缓存(TTL = 10 min)。
*
@@ -74,9 +84,9 @@ public class ConfigAppService {
* 更新系统配置,并清除对应配置缓存。
*/
@Transactional
@CacheEvict(cacheNames = "sysConfig", key = "#dto.configKey")
@CacheEvict(cacheNames = "sysConfig", allEntries = true)
public void updateConfig(ConfigUpdateDTO dto) {
SysConfig existing = configMapper.selectById(dto.getId());
SysConfig existing = getConfigById(dto.getId());
existing.setConfigName(dto.getConfigName());
existing.setConfigKey(dto.getConfigKey());
existing.setConfigValue(dto.getConfigValue());
@@ -87,4 +97,16 @@ public class ConfigAppService {
existing.setVersion(dto.getVersion());
configMapper.updateById(existing);
}
@Transactional
@CacheEvict(cacheNames = "sysConfig", allEntries = true)
public void deleteConfig(Long id) {
getConfigById(id);
configMapper.deleteById(id);
}
@CacheEvict(cacheNames = "sysConfig", allEntries = true)
public void refreshConfigs() {
// Cache eviction is handled by the annotation.
}
}
@@ -1,6 +1,8 @@
package com.ljwx.platform.app.appservice;
import com.ljwx.platform.app.domain.dto.DictCreateDTO;
import com.ljwx.platform.app.domain.dto.DictDataCreateDTO;
import com.ljwx.platform.app.domain.dto.DictDataUpdateDTO;
import com.ljwx.platform.app.domain.dto.DictQueryDTO;
import com.ljwx.platform.app.domain.dto.DictUpdateDTO;
import com.ljwx.platform.app.domain.entity.SysDictData;
@@ -8,7 +10,9 @@ import com.ljwx.platform.app.domain.entity.SysDictType;
import com.ljwx.platform.app.infra.mapper.SysDictDataMapper;
import com.ljwx.platform.app.infra.mapper.SysDictTypeMapper;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
@@ -42,6 +46,14 @@ public class DictAppService {
return new PageResult<>(records, total);
}
public SysDictType getDictTypeById(Long id) {
SysDictType dictType = dictTypeMapper.selectById(id);
if (dictType == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "字典类型不存在");
}
return dictType;
}
/**
* 按字典类型查询字典数据项,使用 Caffeine 缓存(TTL = 10 min)。
*
@@ -58,6 +70,7 @@ public class DictAppService {
* tenant_id 由 TenantLineInterceptor(MyBatis Interceptor)自动注入,Service 层禁止手动设置。
*/
@Transactional
@CacheEvict(cacheNames = "dictData", allEntries = true)
public Long createDictType(DictCreateDTO dto) {
long id = idGenerator.nextId();
@@ -76,9 +89,9 @@ public class DictAppService {
* 更新字典类型,并清除对应字典数据缓存。
*/
@Transactional
@CacheEvict(cacheNames = "dictData", key = "#dto.dictType")
@CacheEvict(cacheNames = "dictData", allEntries = true)
public void updateDictType(DictUpdateDTO dto) {
SysDictType existing = dictTypeMapper.selectById(dto.getId());
SysDictType existing = getDictTypeById(dto.getId());
existing.setDictName(dto.getDictName());
existing.setDictType(dto.getDictType());
if (dto.getStatus() != null) {
@@ -88,4 +101,64 @@ public class DictAppService {
existing.setVersion(dto.getVersion());
dictTypeMapper.updateById(existing);
}
@Transactional
@CacheEvict(cacheNames = "dictData", allEntries = true)
public void deleteDictType(Long id) {
getDictTypeById(id);
dictTypeMapper.deleteById(id);
}
public SysDictData getDictDataById(Long id) {
SysDictData dictData = dictDataMapper.selectById(id);
if (dictData == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "字典数据不存在");
}
return dictData;
}
@Transactional
@CacheEvict(cacheNames = "dictData", allEntries = true)
public Long createDictData(DictDataCreateDTO dto) {
long id = idGenerator.nextId();
SysDictData dictData = new SysDictData();
dictData.setId(id);
dictData.setDictType(dto.getDictType());
dictData.setDictLabel(dto.getDictLabel());
dictData.setDictValue(dto.getDictValue());
dictData.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0);
dictData.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
dictData.setCssClass(dto.getCssClass());
dictData.setListClass(dto.getListClass());
dictData.setIsDefault(dto.getIsDefault() != null ? dto.getIsDefault() : Boolean.FALSE);
dictData.setRemark(dto.getRemark());
dictDataMapper.insert(dictData);
return id;
}
@Transactional
@CacheEvict(cacheNames = "dictData", allEntries = true)
public void updateDictData(DictDataUpdateDTO dto) {
SysDictData existing = getDictDataById(dto.getId());
existing.setDictType(dto.getDictType());
existing.setDictLabel(dto.getDictLabel());
existing.setDictValue(dto.getDictValue());
existing.setSortOrder(dto.getSortOrder());
existing.setStatus(dto.getStatus());
existing.setCssClass(dto.getCssClass());
existing.setListClass(dto.getListClass());
existing.setIsDefault(dto.getIsDefault());
existing.setRemark(dto.getRemark());
existing.setVersion(dto.getVersion());
dictDataMapper.updateById(existing);
}
@Transactional
@CacheEvict(cacheNames = "dictData", allEntries = true)
public void deleteDictData(Long id) {
getDictDataById(id);
dictDataMapper.deleteById(id);
}
}
@@ -8,7 +8,9 @@ import com.ljwx.platform.app.infra.mapper.SysJobMapper;
import com.ljwx.platform.app.infra.quartz.QuartzJobDispatcher;
import com.ljwx.platform.core.context.CurrentTenantHolder;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
@@ -48,6 +50,14 @@ public class JobAppService {
return new PageResult<>(records, total);
}
public SysJob getJobById(Long id) {
SysJob job = jobMapper.selectById(id);
if (job == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "任务不存在");
}
return job;
}
@Transactional
public Long createJob(JobCreateDTO dto) throws SchedulerException {
long id = idGenerator.nextId();
@@ -82,7 +92,7 @@ public class JobAppService {
public void updateJob(JobUpdateDTO dto) throws SchedulerException {
Long tenantId = tenantHolder.getTenantId();
SysJob existing = jobMapper.selectById(dto.getId());
SysJob existing = getJobById(dto.getId());
existing.setJobName(dto.getJobName());
if (dto.getJobGroup() != null) {
existing.setJobGroup(dto.getJobGroup());
@@ -114,7 +124,7 @@ public class JobAppService {
JobKey jobKey = new JobKey(String.valueOf(id), "TENANT_" + tenantId);
scheduler.pauseJob(jobKey);
SysJob job = jobMapper.selectById(id);
SysJob job = getJobById(id);
job.setStatus(0);
jobMapper.updateById(job);
}
@@ -125,8 +135,16 @@ public class JobAppService {
JobKey jobKey = new JobKey(String.valueOf(id), "TENANT_" + tenantId);
scheduler.resumeJob(jobKey);
SysJob job = jobMapper.selectById(id);
SysJob job = getJobById(id);
job.setStatus(1);
jobMapper.updateById(job);
}
@Transactional
public void deleteJob(Long id) throws SchedulerException {
Long tenantId = tenantHolder.getTenantId();
getJobById(id);
scheduler.deleteJob(new JobKey(String.valueOf(id), "TENANT_" + tenantId));
jobMapper.deleteById(id);
}
}
@@ -46,6 +46,17 @@ public class NoticeAppService {
return new PageResult<>(records, total);
}
/**
* 查询通知详情。
*/
public SysNotice getNotice(Long id) {
SysNotice notice = noticeMapper.selectById(id);
if (notice == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "通知不存在");
}
return notice;
}
/**
* 创建通知/公告。
* tenant_id 由 TenantLineInterceptor 自动注入,禁止手动设置。
@@ -142,4 +153,16 @@ public class NoticeAppService {
if (userId == null) return 0L;
return noticeUserMapper.countUnread(userId);
}
/**
* 删除通知。
*/
@Transactional
public void deleteNotice(Long id) {
SysNotice notice = noticeMapper.selectById(id);
if (notice == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "通知不存在");
}
noticeMapper.deleteById(id);
}
}
@@ -4,10 +4,13 @@ import com.ljwx.platform.app.domain.dto.OperationLogQueryDTO;
import com.ljwx.platform.app.domain.entity.SysOperationLog;
import com.ljwx.platform.app.infra.mapper.SysOperationLogMapper;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.regex.Pattern;
@@ -76,6 +79,14 @@ public class OperationLogAppService {
return new PageResult<>(records, total);
}
public SysOperationLog getOperationLogById(Long id) {
SysOperationLog log = operationLogMapper.selectById(id);
if (log == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "操作日志不存在");
}
return log;
}
/**
* 导出操作日志(返回列表,不设页大小限制;调用方可进一步处理为文件)。
*/
@@ -85,6 +96,17 @@ public class OperationLogAppService {
return operationLogMapper.selectList(query);
}
@Transactional
public void deleteOperationLog(Long id) {
getOperationLogById(id);
operationLogMapper.deleteById(id);
}
@Transactional
public int cleanOperationLogs() {
return operationLogMapper.cleanAll();
}
// ── 私有工具方法 ─────────────────────────────────────────────
/**
@@ -0,0 +1,128 @@
package com.ljwx.platform.app.appservice;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ljwx.platform.app.domain.Tenant;
import com.ljwx.platform.app.domain.dto.TenantCreateDTO;
import com.ljwx.platform.app.domain.dto.TenantQueryDTO;
import com.ljwx.platform.app.domain.dto.TenantUpdateDTO;
import com.ljwx.platform.app.domain.vo.TenantVO;
import com.ljwx.platform.app.mapper.TenantMapper;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
/**
* 租户管理应用服务。
*/
@Service
@RequiredArgsConstructor
public class TenantAppService {
private final TenantMapper tenantMapper;
private final SnowflakeIdGenerator idGenerator;
public PageResult<TenantVO> listTenants(TenantQueryDTO query) {
LambdaQueryWrapper<Tenant> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Tenant::getDeleted, false);
if (StringUtils.hasText(query.getName())) {
wrapper.like(Tenant::getName, query.getName().trim());
}
if (StringUtils.hasText(query.getCode())) {
wrapper.like(Tenant::getCode, query.getCode().trim());
}
if (query.getStatus() != null) {
wrapper.eq(Tenant::getStatus, query.getStatus());
}
wrapper.orderByDesc(Tenant::getCreatedTime);
Page<Tenant> page = new Page<>(query.getPageNum(), query.getPageSize());
Page<Tenant> result = tenantMapper.selectPage(page, wrapper);
List<TenantVO> rows = result.getRecords().stream()
.map(this::toVO)
.collect(Collectors.toList());
return new PageResult<>(rows, result.getTotal());
}
public TenantVO getTenant(Long id) {
Tenant tenant = requireTenant(id);
return toVO(tenant);
}
@Transactional
public Long createTenant(TenantCreateDTO dto) {
ensureCodeUnique(dto.getCode(), null);
Tenant tenant = new Tenant();
tenant.setId(idGenerator.nextId());
tenant.setName(dto.getName().trim());
tenant.setCode(dto.getCode().trim());
tenant.setStatus(1);
tenant.setLifecycleStatus("ACTIVE");
tenant.setTenantId(0L);
tenant.setDeleted(false);
tenant.setVersion(1);
tenantMapper.insert(tenant);
return tenant.getId();
}
@Transactional
public void updateTenant(Long id, TenantUpdateDTO dto) {
Tenant tenant = requireTenant(id);
if (StringUtils.hasText(dto.getCode())) {
ensureCodeUnique(dto.getCode(), id);
tenant.setCode(dto.getCode().trim());
}
if (StringUtils.hasText(dto.getName())) {
tenant.setName(dto.getName().trim());
}
if (dto.getStatus() != null) {
tenant.setStatus(dto.getStatus());
}
tenantMapper.updateById(tenant);
}
@Transactional
public void deleteTenant(Long id) {
Tenant tenant = requireTenant(id);
tenant.setDeleted(true);
tenantMapper.updateById(tenant);
}
private Tenant requireTenant(Long id) {
Tenant tenant = tenantMapper.selectById(id);
if (tenant == null || Boolean.TRUE.equals(tenant.getDeleted())) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "租户不存在");
}
return tenant;
}
private void ensureCodeUnique(String code, Long excludeId) {
LambdaQueryWrapper<Tenant> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Tenant::getCode, code.trim())
.eq(Tenant::getDeleted, false);
Tenant existing = tenantMapper.selectOne(wrapper);
if (existing != null && (excludeId == null || !existing.getId().equals(excludeId))) {
throw new BusinessException(ErrorCode.PARAM_VALIDATION_FAILED, "租户编码已存在");
}
}
private TenantVO toVO(Tenant tenant) {
TenantVO vo = new TenantVO();
BeanUtils.copyProperties(tenant, vo);
return vo;
}
}
@@ -13,6 +13,13 @@ import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@@ -150,8 +157,10 @@ public class TenantDomainAppService {
throw new BusinessException(ErrorCode.DOMAIN_NOT_FOUND);
}
// BL-43-08: 域名验证(简化版,实际应查询 DNS TXT 记录)
// 这里仅做标记,实际 DNS 验证需要集成 DNS 查询服务
if (!hasMatchingVerifyRecord(domain.getDomain(), domain.getVerifyToken())) {
throw new BusinessException(ErrorCode.DOMAIN_VERIFY_FAILED);
}
domain.setVerified(true);
domain.setVerifiedTime(LocalDateTime.now());
tenantDomainMapper.updateById(domain);
@@ -165,4 +174,39 @@ public class TenantDomainAppService {
BeanUtils.copyProperties(domain, vo);
return vo;
}
private boolean hasMatchingVerifyRecord(String domain, String verifyToken) {
String recordName = "_ljwx-verify." + domain;
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
DirContext context = null;
try {
context = new InitialDirContext(env);
Attributes attributes = context.getAttributes(recordName, new String[]{"TXT"});
Attribute txt = attributes.get("TXT");
if (txt == null) {
return false;
}
NamingEnumeration<?> values = txt.getAll();
while (values.hasMore()) {
String candidate = String.valueOf(values.next()).replace("\"", "").trim();
if (verifyToken.equals(candidate)) {
return true;
}
}
return false;
} catch (Exception e) {
log.warn("Tenant domain TXT lookup failed for {}: {}", recordName, e.getMessage());
throw new BusinessException(ErrorCode.DOMAIN_VERIFY_FAILED);
} finally {
if (context != null) {
try {
context.close();
} catch (Exception ignored) {
}
}
}
}
}
@@ -33,8 +33,11 @@ public class AuthController {
@PreAuthorize("permitAll()")
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
return Result.ok(authAppService.login(dto));
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto, HttpServletRequest request) {
return Result.ok(authAppService.login(
dto,
extractClientIp(request),
request.getHeader("User-Agent")));
}
@PreAuthorize("permitAll()")
@@ -57,4 +60,12 @@ public class AuthController {
authAppService.logout(token);
return Result.ok();
}
private String extractClientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
return forwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -10,6 +10,7 @@ import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -23,7 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
* 权限按 spec/03-api.md §Configs 路由定义。
*/
@RestController
@RequestMapping("/api/configs")
@RequestMapping({"/api/v1/configs", "/api/configs"})
@RequiredArgsConstructor
public class ConfigController {
@@ -50,9 +51,29 @@ public class ConfigController {
return Result.ok();
}
@PreAuthorize("hasAuthority('config:read')")
@GetMapping("/{id}")
public Result<SysConfig> detail(@PathVariable Long id) {
return Result.ok(configAppService.getConfigById(id));
}
@PreAuthorize("hasAuthority('config:write')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
configAppService.deleteConfig(id);
return Result.ok();
}
@PreAuthorize("hasAuthority('config:read')")
@GetMapping("/key/{key}")
public Result<SysConfig> getByKey(@PathVariable String key) {
return Result.ok(configAppService.getConfigByKey(key));
}
@PreAuthorize("hasAuthority('config:write')")
@PostMapping("/refresh")
public Result<Void> refresh() {
configAppService.refreshConfigs();
return Result.ok();
}
}
@@ -2,6 +2,8 @@ package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.appservice.DictAppService;
import com.ljwx.platform.app.domain.dto.DictCreateDTO;
import com.ljwx.platform.app.domain.dto.DictDataCreateDTO;
import com.ljwx.platform.app.domain.dto.DictDataUpdateDTO;
import com.ljwx.platform.app.domain.dto.DictQueryDTO;
import com.ljwx.platform.app.domain.dto.DictUpdateDTO;
import com.ljwx.platform.app.domain.entity.SysDictData;
@@ -10,6 +12,7 @@ import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -26,26 +29,32 @@ import java.util.List;
* 权限按 spec/03-api.md §Dicts 路由定义。
*/
@RestController
@RequestMapping("/api/dicts")
@RequestMapping({"/api/v1/dicts", "/api/dicts"})
@RequiredArgsConstructor
public class DictController {
private final DictAppService dictAppService;
@PreAuthorize("hasAuthority('dict:read')")
@GetMapping
@GetMapping({"", "/types"})
public Result<PageResult<SysDictType>> list(DictQueryDTO query) {
return Result.ok(dictAppService.listDictTypes(query));
}
@PreAuthorize("hasAuthority('dict:write')")
@PostMapping
@PostMapping({"", "/types"})
public Result<Long> create(@RequestBody @Valid DictCreateDTO dto) {
return Result.ok(dictAppService.createDictType(dto));
}
@PreAuthorize("hasAuthority('dict:read')")
@GetMapping({"/{id}", "/types/{id}"})
public Result<SysDictType> detail(@PathVariable Long id) {
return Result.ok(dictAppService.getDictTypeById(id));
}
@PreAuthorize("hasAuthority('dict:write')")
@PutMapping("/{id}")
@PutMapping({"/{id}", "/types/{id}"})
public Result<Void> update(@PathVariable Long id,
@RequestBody @Valid DictUpdateDTO dto) {
dto.setId(id);
@@ -53,9 +62,38 @@ public class DictController {
return Result.ok();
}
@PreAuthorize("hasAuthority('dict:write')")
@DeleteMapping({"/{id}", "/types/{id}"})
public Result<Void> delete(@PathVariable Long id) {
dictAppService.deleteDictType(id);
return Result.ok();
}
@PreAuthorize("hasAuthority('dict:read')")
@GetMapping("/type/{type}")
public Result<List<SysDictData>> getDictDataByType(@PathVariable String type) {
return Result.ok(dictAppService.getDictDataByType(type));
@GetMapping({"/type/{dictType}", "/data/{dictType}"})
public Result<List<SysDictData>> getDictDataByType(@PathVariable String dictType) {
return Result.ok(dictAppService.getDictDataByType(dictType));
}
@PreAuthorize("hasAuthority('dict:write')")
@PostMapping("/data")
public Result<Long> createData(@RequestBody @Valid DictDataCreateDTO dto) {
return Result.ok(dictAppService.createDictData(dto));
}
@PreAuthorize("hasAuthority('dict:write')")
@PutMapping("/data/{id}")
public Result<Void> updateData(@PathVariable Long id,
@RequestBody @Valid DictDataUpdateDTO dto) {
dto.setId(id);
dictAppService.updateDictData(dto);
return Result.ok();
}
@PreAuthorize("hasAuthority('dict:write')")
@DeleteMapping("/data/{id}")
public Result<Void> deleteData(@PathVariable Long id) {
dictAppService.deleteDictData(id);
return Result.ok();
}
}
@@ -37,7 +37,7 @@ import java.nio.charset.StandardCharsets;
* </ul>
*/
@RestController
@RequestMapping("/api/files")
@RequestMapping({"/api/v1/files", "/api/files"})
@RequiredArgsConstructor
public class FileController {
@@ -9,6 +9,7 @@ import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.*;
/**
@@ -29,7 +30,7 @@ public class ImportExportController {
*/
@PreAuthorize("hasAuthority('system:importExport:import')")
@PostMapping("/import")
public Result<Long> importData(@Valid @RequestBody ImportExportTaskDTO dto) {
public Result<Long> importData(@Valid @ModelAttribute ImportExportTaskDTO dto) {
Long taskId = importExportService.createImportTask(dto);
return Result.ok(taskId);
}
@@ -9,6 +9,7 @@ import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.quartz.SchedulerException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -24,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
* 权限按 spec/03-api.md §Jobs 路由定义。
*/
@RestController
@RequestMapping("/api/jobs")
@RequestMapping({"/api/v1/jobs", "/api/jobs"})
@RequiredArgsConstructor
public class JobController {
@@ -42,6 +43,12 @@ public class JobController {
return Result.ok(jobAppService.createJob(dto));
}
@PreAuthorize("hasAuthority('job:read')")
@GetMapping("/{id}")
public Result<SysJob> detail(@PathVariable Long id) {
return Result.ok(jobAppService.getJobById(id));
}
@PreAuthorize("hasAuthority('job:write')")
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id,
@@ -52,7 +59,7 @@ public class JobController {
}
@PreAuthorize("hasAuthority('job:execute')")
@PostMapping("/{id}/execute")
@PostMapping({"/{id}/execute", "/{id}/run"})
public Result<Void> execute(@PathVariable Long id) throws SchedulerException {
jobAppService.executeNow(id);
return Result.ok();
@@ -71,4 +78,11 @@ public class JobController {
jobAppService.resumeJob(id);
return Result.ok();
}
@PreAuthorize("hasAuthority('job:write')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) throws SchedulerException {
jobAppService.deleteJob(id);
return Result.ok();
}
}
@@ -17,14 +17,14 @@ import org.springframework.web.bind.annotation.RestController;
* <p>路由:GET /api/v1/login-logs — 分页查询登录日志
*/
@RestController
@RequestMapping("/api/v1/login-logs")
@RequestMapping({"/api/v1", "/api"})
@RequiredArgsConstructor
public class LoginLogController {
private final LoginLogAppService loginLogAppService;
@PreAuthorize("hasAuthority('system:log:login:list')")
@GetMapping
@GetMapping({"/login-logs", "/logs/login"})
public Result<PageResult<LoginLogVO>> list(LoginLogQueryDTO query) {
return Result.ok(loginLogAppService.listLoginLogs(query));
}
@@ -10,6 +10,7 @@ import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -17,7 +18,6 @@ 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.RestController;
import org.springframework.web.bind.annotation.DeleteMapping;
/**
* 系统通知/公告 Controller。
@@ -31,7 +31,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
* </ul>
*/
@RestController
@RequestMapping("/api/notices")
@RequestMapping({"/api/v1/notices", "/api/notices"})
@RequiredArgsConstructor
public class NoticeController {
@@ -77,6 +77,25 @@ public class NoticeController {
return Result.ok();
}
/**
* 获取通知详情。
*/
@PreAuthorize("hasAuthority('notice:read')")
@GetMapping("/{id}")
public Result<SysNotice> getById(@PathVariable Long id) {
return Result.ok(noticeAppService.getNotice(id));
}
/**
* 删除通知。
*/
@PreAuthorize("hasAuthority('notice:write')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
noticeAppService.deleteNotice(id);
return Result.ok();
}
/**
* 获取当前用户未读通知数。
*/
@@ -7,7 +7,9 @@ import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -25,7 +27,7 @@ import java.util.List;
* </ul>
*/
@RestController
@RequestMapping("/api/logs")
@RequestMapping({"/api/v1", "/api"})
@RequiredArgsConstructor
public class OperationLogController {
@@ -35,17 +37,36 @@ public class OperationLogController {
* 查询操作日志列表(分页)。
*/
@PreAuthorize("hasAuthority('log:read')")
@GetMapping("/operation")
@GetMapping("/logs/operation")
public Result<PageResult<SysOperationLog>> listOperationLogs(OperationLogQueryDTO query) {
return Result.ok(operationLogAppService.listOperationLogs(query));
}
@PreAuthorize("hasAuthority('log:read')")
@GetMapping("/logs/operation/{id}")
public Result<SysOperationLog> getOperationLog(@PathVariable Long id) {
return Result.ok(operationLogAppService.getOperationLogById(id));
}
/**
* 导出操作日志(返回 JSON 列表,客户端可自行处理为 Excel)。
*/
@PreAuthorize("hasAuthority('log:export')")
@PostMapping("/export")
@PostMapping("/logs/export")
public Result<List<SysOperationLog>> exportLogs(OperationLogQueryDTO query) {
return Result.ok(operationLogAppService.exportLogs(query));
}
@PreAuthorize("hasAuthority('log:write')")
@DeleteMapping("/logs/operation/{id}")
public Result<Void> deleteOperationLog(@PathVariable Long id) {
operationLogAppService.deleteOperationLog(id);
return Result.ok();
}
@PreAuthorize("hasAuthority('log:write')")
@DeleteMapping("/logs/operation/clean")
public Result<Integer> cleanOperationLogs() {
return Result.ok(operationLogAppService.cleanOperationLogs());
}
}
@@ -0,0 +1,27 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.service.OperationsDashboardService;
import com.ljwx.platform.app.vo.ops.OperationsDashboardVO;
import com.ljwx.platform.core.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Operations dashboard controller.
*/
@RestController
@RequestMapping("/api/v1/ops")
@RequiredArgsConstructor
public class OperationsDashboardController {
private final OperationsDashboardService operationsDashboardService;
@PreAuthorize("hasAuthority('system:ops:dashboard')")
@GetMapping("/dashboard")
public Result<OperationsDashboardVO> getDashboard() {
return Result.ok(operationsDashboardService.getDashboard());
}
}
@@ -16,7 +16,7 @@ import java.util.List;
* 权限列表用于角色配置时的权限选择器。
*/
@RestController
@RequestMapping("/api/permissions")
@RequestMapping({"/api/v1/permissions", "/api/permissions"})
@RequiredArgsConstructor
public class PermissionController {
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
* 角色管理 Controller。
*/
@RestController
@RequestMapping("/api/roles")
@RequestMapping({"/api/v1/roles", "/api/roles"})
@RequiredArgsConstructor
public class RoleController {
@@ -0,0 +1,41 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.service.ScreenService;
import com.ljwx.platform.app.vo.screen.ScreenOverviewVO;
import com.ljwx.platform.app.vo.screen.ScreenRealtimeVO;
import com.ljwx.platform.app.vo.screen.ScreenTrendVO;
import com.ljwx.platform.core.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Screen controller.
*/
@RestController
@RequestMapping("/api/v1/screen")
@RequiredArgsConstructor
public class ScreenController {
private final ScreenService screenService;
@PreAuthorize("hasAnyAuthority('system:screen:read', 'screen:read')")
@GetMapping("/overview")
public Result<ScreenOverviewVO> overview() {
return Result.ok(screenService.getOverview());
}
@PreAuthorize("hasAnyAuthority('system:screen:read', 'screen:read')")
@GetMapping("/realtime")
public Result<ScreenRealtimeVO> realtime() {
return Result.ok(screenService.getRealtime());
}
@PreAuthorize("hasAnyAuthority('system:screen:read', 'screen:read')")
@GetMapping("/trend")
public Result<ScreenTrendVO> trend() {
return Result.ok(screenService.getTrend());
}
}
@@ -0,0 +1,63 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.appservice.TenantAppService;
import com.ljwx.platform.app.domain.dto.TenantCreateDTO;
import com.ljwx.platform.app.domain.dto.TenantQueryDTO;
import com.ljwx.platform.app.domain.dto.TenantUpdateDTO;
import com.ljwx.platform.app.domain.vo.TenantVO;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;
/**
* 租户管理控制器。
*/
@RestController
@RequestMapping("/api/v1/tenants")
@RequiredArgsConstructor
public class TenantController {
private final TenantAppService tenantAppService;
@PreAuthorize("hasAuthority('system:tenant:list')")
@GetMapping
public Result<PageResult<TenantVO>> list(TenantQueryDTO query) {
return Result.ok(tenantAppService.listTenants(query));
}
@PreAuthorize("hasAuthority('system:tenant:detail')")
@GetMapping("/{id}")
public Result<TenantVO> getById(@PathVariable Long id) {
return Result.ok(tenantAppService.getTenant(id));
}
@PreAuthorize("hasAuthority('system:tenant:create')")
@PostMapping
public Result<Long> create(@Valid @RequestBody TenantCreateDTO dto) {
return Result.ok(tenantAppService.createTenant(dto));
}
@PreAuthorize("hasAuthority('system:tenant:update')")
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody TenantUpdateDTO dto) {
tenantAppService.updateTenant(id, dto);
return Result.ok();
}
@PreAuthorize("hasAuthority('system:tenant:delete')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
tenantAppService.deleteTenant(id);
return Result.ok();
}
}
@@ -35,7 +35,7 @@ import java.util.List;
* 用户管理 Controller。
*/
@RestController
@RequestMapping("/api/users")
@RequestMapping({"/api/v1/users", "/api/users"})
@RequiredArgsConstructor
public class UserController {
@@ -2,9 +2,11 @@ package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.dto.WfDefinitionDTO;
import com.ljwx.platform.app.dto.WfInstanceDTO;
import com.ljwx.platform.app.dto.WfInstanceQueryDTO;
import com.ljwx.platform.app.dto.WfTaskDTO;
import com.ljwx.platform.app.service.WorkflowService;
import com.ljwx.platform.app.vo.WfInstanceVO;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -77,6 +79,14 @@ public class WorkflowController {
return Result.ok(id);
}
/**
* 查询流程实例列表
*/
@GetMapping("/instances") @PreAuthorize("hasAuthority('system:workflow:instance:query')")
public Result<PageResult<WfInstanceVO>> listInstances(WfInstanceQueryDTO query) {
return Result.ok(workflowService.listInstances(query));
}
/**
* 查询流程实例详情
*/
@@ -0,0 +1,32 @@
package com.ljwx.platform.app.domain.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 创建字典数据请求。
*/
@Data
public class DictDataCreateDTO {
@NotBlank
private String dictType;
@NotBlank
private String dictLabel;
@NotBlank
private String dictValue;
private Integer sortOrder = 0;
private Integer status = 1;
private String cssClass;
private String listClass;
private Boolean isDefault = Boolean.FALSE;
private String remark;
}
@@ -0,0 +1,39 @@
package com.ljwx.platform.app.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 更新字典数据请求。
*/
@Data
public class DictDataUpdateDTO {
@NotNull
private Long id;
@NotBlank
private String dictType;
@NotBlank
private String dictLabel;
@NotBlank
private String dictValue;
private Integer sortOrder = 0;
private Integer status = 1;
private String cssClass;
private String listClass;
private Boolean isDefault = Boolean.FALSE;
private String remark;
@NotNull
private Integer version;
}
@@ -0,0 +1,20 @@
package com.ljwx.platform.app.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 租户创建 DTO。
*/
@Data
public class TenantCreateDTO {
@NotBlank(message = "租户名称不能为空")
@Size(max = 100, message = "租户名称长度不能超过 100")
private String name;
@NotBlank(message = "租户编码不能为空")
@Size(max = 50, message = "租户编码长度不能超过 50")
private String code;
}
@@ -0,0 +1,20 @@
package com.ljwx.platform.app.domain.dto;
import lombok.Data;
/**
* 租户查询 DTO。
*/
@Data
public class TenantQueryDTO {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String name;
private String code;
private Integer status;
public int getOffset() {
return (Math.max(pageNum, 1) - 1) * Math.max(pageSize, 1);
}
}
@@ -0,0 +1,19 @@
package com.ljwx.platform.app.domain.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 租户更新 DTO。
*/
@Data
public class TenantUpdateDTO {
@Size(max = 100, message = "租户名称长度不能超过 100")
private String name;
@Size(max = 50, message = "租户编码长度不能超过 50")
private String code;
private Integer status;
}
@@ -0,0 +1,24 @@
package com.ljwx.platform.app.domain.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 租户视图对象。
*/
@Data
public class TenantVO {
private Long id;
private String name;
private String code;
private Integer status;
private String lifecycleStatus;
private String frozenReason;
private LocalDateTime frozenTime;
private String cancelledReason;
private LocalDateTime cancelledTime;
private LocalDateTime createdTime;
private LocalDateTime updatedTime;
}
@@ -0,0 +1,16 @@
package com.ljwx.platform.app.dto;
import lombok.Data;
/**
* 流程实例查询 DTO。
*/
@Data
public class WfInstanceQueryDTO {
private String businessKey;
private String businessType;
private String status;
private Integer pageNum = 1;
private Integer pageSize = 10;
}
@@ -20,4 +20,8 @@ public interface SysOperationLogMapper {
List<SysOperationLog> selectList(OperationLogQueryDTO query);
long countList(OperationLogQueryDTO query);
int deleteById(Long id);
int cleanAll();
}
@@ -1,5 +1,9 @@
package com.ljwx.platform.app.service;
import com.ljwx.platform.app.ai.tool.JobQueryTool;
import com.ljwx.platform.app.ai.tool.LogQueryTool;
import com.ljwx.platform.app.ai.tool.MonitorTool;
import com.ljwx.platform.app.ai.tool.OnlineUserTool;
import com.ljwx.platform.app.domain.AiConfig;
import com.ljwx.platform.app.domain.AiConversationLog;
import com.ljwx.platform.app.dto.ai.AiChatDTO;
@@ -15,8 +19,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* AI 对话服务
@@ -30,6 +34,10 @@ public class AiChatAppService {
private final AiConfigAppService configService;
private final AiConversationLogMapper logMapper;
private final OnlineUserTool onlineUserTool;
private final MonitorTool monitorTool;
private final JobQueryTool jobQueryTool;
private final LogQueryTool logQueryTool;
/**
* 发送消息并获取 AI 回答
@@ -43,28 +51,22 @@ public class AiChatAppService {
*/
@Transactional
public AiChatVO chat(AiChatDTO dto) {
// 1. 加载 AI 配置
Long tenantId = CurrentTenantHolder.get();
AiConfig config = configService.getActiveConfig(tenantId);
// 2. 生成或使用会话 ID
String sessionId = dto.getSessionId();
if (sessionId == null || sessionId.isBlank()) {
sessionId = UUID.randomUUID().toString();
}
// 3. 调用 AI 模型(简化实现:返回占位响应)
// 实际应使用 Spring AI ChatModel
long start = System.currentTimeMillis();
String answer = "这是 AI 助手的占位响应。实际应调用 Spring AI ChatModel。";
List<Map<String, Object>> toolCalls = new ArrayList<>();
int tokensUsed = 100;
String answer = resolveAnswer(dto.getMessage(), toolCalls);
long durationMs = System.currentTimeMillis() - start;
int tokensUsed = estimateTokens(dto.getMessage(), answer, toolCalls);
// 4. 写入审计日志
saveConversationLog(sessionId, dto.getMessage(), answer, toolCalls, tokensUsed, durationMs, config.getModelName());
// 5. 构建响应
return AiChatVO.builder()
.sessionId(sessionId)
.answer(answer)
@@ -84,15 +86,194 @@ public class AiChatAppService {
List<AiConversationLogVO> list = logMapper.selectLogList(query);
long total = logMapper.countLogs(query);
// 处理 toolCallSummary(从 JSONB 提取名称)
list.forEach(vo -> {
// 简化实现:返回空列表
vo.setToolCallSummary(List.of());
AiConversationLog entity = logMapper.selectById(vo.getId());
if (entity == null || entity.getToolCalls() == null) {
vo.setToolCallSummary(List.of());
return;
}
List<String> toolNames = entity.getToolCalls().stream()
.map(call -> String.valueOf(call.getOrDefault("name", "")))
.filter(name -> !name.isBlank())
.distinct()
.toList();
vo.setToolCallSummary(toolNames);
});
return new PageResult<>(list, total);
}
private String resolveAnswer(String message, List<Map<String, Object>> toolCalls) {
String trimmedMessage = message == null ? "" : message.trim();
String lowerCaseMessage = trimmedMessage.toLowerCase(Locale.ROOT);
List<String> sections = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
if (containsAny(lowerCaseMessage, "在线", "online")) {
Map<String, Object> result = onlineUserTool.getOnlineUserCount();
toolCalls.add(buildToolCall("onlineUser.getOnlineUserCount", Map.of(), result));
sections.add("在线用户数: " + result.get("onlineCount"));
}
if (containsAny(lowerCaseMessage, "缓存", "cache")) {
Map<String, Object> result = monitorTool.getCacheStats();
toolCalls.add(buildToolCall("monitor.getCacheStats", Map.of(), result));
sections.add(String.format(Locale.ROOT,
"缓存命中率: %.2f%%, 当前缓存条目: %s",
toPercentage(result.get("hitRate")),
result.get("cacheSize")));
}
if (containsAny(lowerCaseMessage, "监控", "cpu", "内存", "服务器", "jvm")) {
Map<String, Object> result = monitorTool.getServerStatus();
toolCalls.add(buildToolCall("monitor.getServerStatus", Map.of(), result));
sections.add(formatServerStatus(result));
}
if (containsAny(lowerCaseMessage, "任务", "job", "定时")) {
Long jobId = extractFirstLong(trimmedMessage);
if (containsAny(lowerCaseMessage, "日志", "执行") && jobId != null) {
Map<String, Object> result = jobQueryTool.getJobLogs(jobId, 5);
toolCalls.add(buildToolCall("job.getJobLogs", Map.of("jobId", jobId, "limit", 5), result));
sections.add(formatJobLogs(result));
} else {
Map<String, Object> result = jobQueryTool.listScheduledJobs();
toolCalls.add(buildToolCall("job.listScheduledJobs", Map.of(), result));
sections.add(formatJobSummary(result));
}
}
if (containsAny(lowerCaseMessage, "操作日志", "审计日志")
|| (containsAny(lowerCaseMessage, "日志") && containsAny(lowerCaseMessage, "操作"))) {
Map<String, Object> result = logQueryTool.queryOperationLogs(now.minusDays(7), now, null, null, 5);
toolCalls.add(buildToolCall("log.queryOperationLogs",
Map.of("startTime", now.minusDays(7), "endTime", now, "limit", 5), result));
sections.add(formatOperationLogs(result));
}
if (containsAny(lowerCaseMessage, "登录日志", "登录记录", "login")) {
Map<String, Object> result = logQueryTool.queryLoginLogs(now.minusDays(7), now, null, 5);
toolCalls.add(buildToolCall("log.queryLoginLogs",
Map.of("startTime", now.minusDays(7), "endTime", now, "limit", 5), result));
sections.add(formatLoginLogs(result));
}
if (sections.isEmpty()) {
sections.add("当前助手支持查询在线用户、服务器监控、缓存状态、定时任务、操作日志和登录日志。");
sections.add("可直接提问,例如:当前在线用户数、最近 5 条操作日志、任务 123 的执行日志。");
}
return String.join("\n", sections);
}
private Map<String, Object> buildToolCall(String name, Map<String, Object> arguments, Map<String, Object> result) {
Map<String, Object> toolCall = new LinkedHashMap<>();
toolCall.put("name", name);
toolCall.put("arguments", arguments);
toolCall.put("result", result);
return toolCall;
}
private String formatServerStatus(Map<String, Object> result) {
Map<String, Object> cpu = asMap(result.get("cpu"));
Map<String, Object> jvm = asMap(result.get("jvm"));
Map<String, Object> memory = asMap(result.get("memory"));
return String.format(Locale.ROOT,
"CPU 核数: %s, 系统负载: %s, JVM 内存使用: %s MB / %s MB, 非堆内存: %s MB",
cpu.getOrDefault("availableProcessors", 0),
cpu.getOrDefault("systemLoadAverage", 0),
jvm.getOrDefault("usedMemoryMB", 0),
jvm.getOrDefault("maxMemoryMB", 0),
memory.getOrDefault("nonHeapMemoryUsageMB", 0));
}
private String formatJobSummary(Map<String, Object> result) {
List<?> jobs = asList(result.get("jobs"));
if (jobs.isEmpty()) {
return "当前没有可查询的定时任务。";
}
Object first = jobs.get(0);
return "任务总数: " + result.get("total") + ",最近一条: " + first;
}
private String formatJobLogs(Map<String, Object> result) {
List<?> logs = asList(result.get("logs"));
if (logs.isEmpty()) {
return "没有查到该任务的执行日志。";
}
return "任务日志共 " + result.get("total") + " 条,最近一条: " + logs.get(0);
}
private String formatOperationLogs(Map<String, Object> result) {
List<?> logs = asList(result.get("logs"));
if (logs.isEmpty()) {
return "最近 7 天没有查询到操作日志。";
}
return "最近 7 天操作日志 " + result.get("total") + " 条,最新记录: " + logs.get(0);
}
private String formatLoginLogs(Map<String, Object> result) {
List<?> logs = asList(result.get("logs"));
if (logs.isEmpty()) {
return "最近 7 天没有查询到登录日志。";
}
return "最近 7 天登录日志 " + result.get("total") + " 条,最新记录: " + logs.get(0);
}
private double toPercentage(Object ratio) {
if (!(ratio instanceof Number number)) {
return 0D;
}
return number.doubleValue() * 100D;
}
@SuppressWarnings("unchecked")
private Map<String, Object> asMap(Object value) {
if (value instanceof Map<?, ?> map) {
return (Map<String, Object>) map;
}
return Map.of();
}
private List<?> asList(Object value) {
if (value instanceof List<?> list) {
return list;
}
return List.of();
}
private boolean containsAny(String source, String... keywords) {
for (String keyword : keywords) {
if (source.contains(keyword.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private Long extractFirstLong(String text) {
if (text == null || text.isBlank()) {
return null;
}
for (String token : text.split("[^0-9]+")) {
if (!token.isBlank()) {
try {
return Long.parseLong(token);
} catch (NumberFormatException ignored) {
return null;
}
}
}
return null;
}
private int estimateTokens(String question, String answer, List<Map<String, Object>> toolCalls) {
int contentLength = (question == null ? 0 : question.length())
+ (answer == null ? 0 : answer.length())
+ toolCalls.toString().length();
return Math.max(contentLength / 4, 1);
}
/**
* 保存对话日志
*
@@ -28,9 +28,9 @@ import java.util.Base64;
public class AiConfigAppService {
private final AiConfigMapper aiConfigMapper;
private final SensitiveDataCryptoService cryptoService;
// 简化实现:硬编码 AES 密钥(生产环境应从配置中心获取)
private static final String AES_KEY = "ljwx-ai-key-2026";
private static final String LEGACY_AES_KEY = "ljwx-ai-key-2026";
/**
* 获取当前租户的 AI 配置
@@ -44,7 +44,7 @@ public class AiConfigAppService {
AiConfigVO vo = new AiConfigVO();
vo.setProvider(config.getProvider());
vo.setModelName(config.getModelName());
vo.setApiKeyMasked(maskApiKey(decrypt(config.getApiKeyEncrypted())));
vo.setApiKeyMasked(maskApiKey(decryptApiKey(config.getApiKeyEncrypted())));
vo.setBaseUrl(config.getBaseUrl());
vo.setTemperature(config.getTemperature());
vo.setMaxTokens(config.getMaxTokens());
@@ -64,7 +64,7 @@ public class AiConfigAppService {
config.setProvider(dto.getProvider());
config.setModelName(dto.getModelName());
config.setApiKeyEncrypted(encrypt(dto.getApiKey()));
config.setApiKeyEncrypted(cryptoService.encrypt(dto.getApiKey()));
config.setBaseUrl(dto.getBaseUrl());
config.setTemperature(dto.getTemperature());
config.setMaxTokens(dto.getMaxTokens());
@@ -91,33 +91,17 @@ public class AiConfigAppService {
return config;
}
/**
* 加密 API Key
*
* @param apiKey 明文 API Key
* @return 加密后的 API Key
*/
private String encrypt(String apiKey) {
private String decryptApiKey(String encryptedApiKey) {
try {
SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(apiKey.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new BusinessException("API Key 加密失败", e);
return cryptoService.decrypt(encryptedApiKey);
} catch (BusinessException ex) {
return decryptLegacyApiKey(encryptedApiKey);
}
}
/**
* 解密 API Key
*
* @param encryptedApiKey 加密的 API Key
* @return 明文 API Key
*/
private String decrypt(String encryptedApiKey) {
private String decryptLegacyApiKey(String encryptedApiKey) {
try {
SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");
SecretKeySpec keySpec = new SecretKeySpec(LEGACY_AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedApiKey));
@@ -142,4 +126,3 @@ public class AiConfigAppService {
return prefix + "***...***" + suffix;
}
}
@@ -106,10 +106,9 @@ public class FormDataAppService {
formData.setFormDefId(dto.getFormDefId());
formData.setFieldValues(dto.getFieldValues());
// Inject creatorId and creatorDeptId from SecurityContext
Long currentUserId = currentUserHolder.getUserId();
formData.setCreatorId(currentUserId != null ? currentUserId : 0L);
formData.setCreatorDeptId(0L); // TODO: Get from user's department
formData.setCreatorDeptId(0L);
formDataMapper.insert(formData);
return formData.getId();
@@ -13,11 +13,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.scheduling.annotation.Async;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -33,12 +41,22 @@ public class ImportExportService {
private final ImportExportTaskMapper importExportTaskMapper;
private final SnowflakeIdGenerator idGenerator;
private final JdbcTemplate jdbcTemplate;
private static final Path EXPORT_BASE_DIR = Paths.get(
System.getProperty("java.io.tmpdir"), "ljwx-platform", "exports");
private static final Path IMPORT_BASE_DIR = Paths.get(
System.getProperty("java.io.tmpdir"), "ljwx-platform", "imports");
/**
* Create import task
*/
@Transactional
public Long createImportTask(ImportExportTaskDTO dto) {
if (dto.getFile() == null || dto.getFile().isEmpty()) {
throw new BusinessException(ErrorCode.PARAM_VALIDATION_FAILED, "导入必须上传文件");
}
ImportExportTask task = new ImportExportTask();
task.setId(idGenerator.nextId());
task.setTaskType("IMPORT");
@@ -48,10 +66,9 @@ public class ImportExportService {
task.setTotalCount(0);
task.setSuccessCount(0);
task.setFailureCount(0);
task.setFileUrl(storeImportFile(task.getId(), dto).toString());
importExportTaskMapper.insert(task);
// Trigger async processing
processImportTaskAsync(task.getId());
return task.getId();
@@ -73,8 +90,6 @@ public class ImportExportService {
task.setFailureCount(0);
importExportTaskMapper.insert(task);
// Trigger async processing
processExportTaskAsync(task.getId());
return task.getId();
@@ -115,37 +130,17 @@ public class ImportExportService {
try {
log.info("Starting import task processing: {}", taskId);
// Update status to PROCESSING
ImportExportTask task = importExportTaskMapper.selectById(taskId);
task.setStatus("PROCESSING");
importExportTaskMapper.updateById(task);
// TODO: Implement actual import logic
// 1. Read file from MinIO
// 2. Parse Excel
// 3. Validate data
// 4. Import to database
// 5. Update progress
// Simulate processing
Thread.sleep(2000);
// Update status to SUCCESS
task.setStatus("SUCCESS");
task.setTotalCount(100);
task.setSuccessCount(100);
task.setFailureCount(0);
importExportTaskMapper.updateById(task);
log.info("Import task completed: {}", taskId);
task = importExportTaskMapper.selectById(taskId);
failTask(task, "当前版本未配置业务导入解析器,导入任务已拒绝执行");
} catch (Exception e) {
log.error("Import task failed: {}", taskId, e);
// Update status to FAILURE
ImportExportTask task = importExportTaskMapper.selectById(taskId);
task.setStatus("FAILURE");
task.setErrorMessage(e.getMessage());
importExportTaskMapper.updateById(task);
if (task != null) {
failTask(task, e.getMessage());
}
}
}
@@ -157,37 +152,29 @@ public class ImportExportService {
try {
log.info("Starting export task processing: {}", taskId);
// Update status to PROCESSING
ImportExportTask task = importExportTaskMapper.selectById(taskId);
task.setStatus("PROCESSING");
importExportTaskMapper.updateById(task);
task = importExportTaskMapper.selectById(taskId);
// TODO: Implement actual export logic
// 1. Query data from database
// 2. Generate Excel
// 3. Upload to MinIO
// 4. Update file URL
ExportDataset dataset = buildExportDataset(task.getBusinessType());
Path exportFile = writeCsv(task.getId(), task.getFileName(), dataset);
// Simulate processing
Thread.sleep(2000);
// Update status to SUCCESS
task.setStatus("SUCCESS");
task.setTotalCount(100);
task.setSuccessCount(100);
task.setTotalCount(dataset.rows().size());
task.setSuccessCount(dataset.rows().size());
task.setFailureCount(0);
task.setFileUrl("http://minio.example.com/exports/file.xlsx");
task.setFileUrl(exportFile.toUri().toString());
importExportTaskMapper.updateById(task);
log.info("Export task completed: {}", taskId);
} catch (Exception e) {
log.error("Export task failed: {}", taskId, e);
// Update status to FAILURE
ImportExportTask task = importExportTaskMapper.selectById(taskId);
task.setStatus("FAILURE");
task.setErrorMessage(e.getMessage());
importExportTaskMapper.updateById(task);
if (task != null) {
failTask(task, e.getMessage());
}
}
}
@@ -199,4 +186,104 @@ public class ImportExportService {
BeanUtils.copyProperties(task, vo);
return vo;
}
private Path storeImportFile(Long taskId, ImportExportTaskDTO dto) {
try {
Files.createDirectories(IMPORT_BASE_DIR);
Path filePath = IMPORT_BASE_DIR.resolve(taskId + "-" + sanitizeFileName(dto.getFileName()));
dto.getFile().transferTo(filePath);
return filePath;
} catch (IOException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "导入文件保存失败");
}
}
private ExportDataset buildExportDataset(String businessType) {
return switch (businessType) {
case "USER" -> exportQuery(
List.of("id", "username", "nickname", "email", "phone", "status", "created_time"),
"SELECT id, username, nickname, email, phone, status, created_time " +
"FROM sys_user WHERE deleted = FALSE ORDER BY created_time DESC");
case "ROLE" -> exportQuery(
List.of("id", "name", "code", "status", "created_time"),
"SELECT id, name, code, status, created_time " +
"FROM sys_role WHERE deleted = FALSE ORDER BY created_time DESC");
case "DEPT" -> exportQuery(
List.of("id", "name", "parent_id", "sort", "status", "created_time"),
"SELECT id, name, parent_id, sort, status, created_time " +
"FROM sys_dept WHERE deleted = FALSE ORDER BY sort ASC, id ASC");
case "MENU" -> exportQuery(
List.of("id", "name", "parent_id", "path", "component", "permission", "sort", "visible"),
"SELECT id, name, parent_id, path, component, permission, sort, visible " +
"FROM sys_menu WHERE deleted = FALSE ORDER BY sort ASC, id ASC");
default -> throw new BusinessException(ErrorCode.PARAM_VALIDATION_FAILED,
"不支持的导出业务类型: " + businessType);
};
}
private ExportDataset exportQuery(List<String> headers, String sql) {
List<Map<String, Object>> rows = jdbcTemplate.query(
sql,
(rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
for (String header : headers) {
row.put(header, rs.getObject(header));
}
return row;
});
return new ExportDataset(headers, rows);
}
private Path writeCsv(Long taskId, String originalFileName, ExportDataset dataset) {
try {
Files.createDirectories(EXPORT_BASE_DIR);
Path filePath = EXPORT_BASE_DIR.resolve(taskId + "-" + normalizeExportFileName(originalFileName));
try (BufferedWriter writer = Files.newBufferedWriter(filePath, StandardCharsets.UTF_8)) {
writer.write(String.join(",", dataset.headers()));
writer.newLine();
for (Map<String, Object> row : dataset.rows()) {
writer.write(dataset.headers().stream()
.map(header -> csvValue(row.get(header)))
.collect(Collectors.joining(",")));
writer.newLine();
}
}
return filePath;
} catch (IOException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "导出文件生成失败");
}
}
private void failTask(ImportExportTask task, String errorMessage) {
task.setStatus("FAILURE");
task.setTotalCount(0);
task.setSuccessCount(0);
task.setFailureCount(0);
task.setErrorMessage(errorMessage);
importExportTaskMapper.updateById(task);
}
private String normalizeExportFileName(String originalFileName) {
String baseName = sanitizeFileName(originalFileName);
if (baseName.endsWith(".csv")) {
return baseName;
}
int dotIndex = baseName.lastIndexOf('.');
if (dotIndex > 0) {
baseName = baseName.substring(0, dotIndex);
}
return baseName + ".csv";
}
private String sanitizeFileName(String originalFileName) {
return originalFileName.replaceAll("[^a-zA-Z0-9._-]", "_");
}
private String csvValue(Object value) {
String text = value == null ? "" : String.valueOf(value);
return "\"" + text.replace("\"", "\"\"") + "\"";
}
private record ExportDataset(List<String> headers, List<Map<String, Object>> rows) {
}
}
@@ -61,11 +61,17 @@ public class MessageService {
record.setReceiverAddress(dto.getReceiverAddress());
record.setSubject(dto.getSubject());
record.setContent(dto.getContent());
record.setSendStatus("PENDING");
if ("INBOX".equals(dto.getMessageType())) {
record.setSendStatus("SUCCESS");
record.setSendTime(LocalDateTime.now());
} else {
record.setSendStatus("FAILURE");
record.setErrorMessage("当前环境未配置 " + dto.getMessageType() + " 发送通道");
}
msgRecordMapper.insert(record);
// 如果是站内信,写入收件箱
if ("INBOX".equals(dto.getMessageType())) {
MsgUserInbox inbox = new MsgUserInbox();
inbox.setId(idGenerator.nextId());
@@ -78,8 +84,8 @@ public class MessageService {
msgUserInboxMapper.insert(inbox);
}
// TODO: 异步处理邮件和短信发送
log.info("消息发送请求已创建,消息ID: {}, 类型: {}", record.getId(), dto.getMessageType());
log.info("消息记录已创建,消息ID: {}, 类型: {}, 状态: {}",
record.getId(), dto.getMessageType(), record.getSendStatus());
return record.getId();
}
@@ -33,6 +33,7 @@ public class OpenAppSecretService {
private final OpenAppSecretMapper secretMapper;
private final SnowflakeIdGenerator idGenerator;
private final SensitiveDataCryptoService cryptoService;
private static final int MAX_ACTIVE_SECRETS = 3;
private static final String STATUS_ACTIVE = "ACTIVE";
@@ -63,7 +64,7 @@ public class OpenAppSecretService {
OpenAppSecret secret = new OpenAppSecret();
secret.setId(idGenerator.nextId());
secret.setAppId(dto.getAppId());
secret.setSecretKey(plainSecretKey); // TODO: Encrypt in production
secret.setSecretKey(cryptoService.encrypt(plainSecretKey));
secret.setSecretVersion(newVersion);
secret.setStatus(STATUS_ACTIVE);
@@ -74,9 +75,9 @@ public class OpenAppSecretService {
secretMapper.insert(secret);
// Return VO with plain text key (only on creation)
OpenAppSecretVO vo = new OpenAppSecretVO();
BeanUtils.copyProperties(secret, vo);
vo.setSecretKey(plainSecretKey);
return vo;
}
@@ -136,12 +137,7 @@ public class OpenAppSecretService {
return secrets.stream().map(secret -> {
OpenAppSecretVO vo = new OpenAppSecretVO();
BeanUtils.copyProperties(secret, vo);
// Mask secret key (show only first 8 chars)
if (secret.getSecretKey() != null && secret.getSecretKey().length() > 8) {
vo.setSecretKey(secret.getSecretKey().substring(0, 8) + "****");
}
vo.setSecretKey(maskSecretKey(secret.getSecretKey()));
return vo;
}).collect(Collectors.toList());
}
@@ -162,4 +158,22 @@ public class OpenAppSecretService {
throw new RuntimeException("Failed to generate secret key", e);
}
}
private String maskSecretKey(String storedSecretKey) {
if (storedSecretKey == null || storedSecretKey.isBlank()) {
return "";
}
String plainSecretKey = storedSecretKey;
try {
plainSecretKey = cryptoService.decrypt(storedSecretKey);
} catch (BusinessException ignored) {
// Keep compatibility with historical plaintext rows.
}
if (plainSecretKey.length() <= 8) {
return "****";
}
return plainSecretKey.substring(0, 8) + "****";
}
}
@@ -0,0 +1,215 @@
package com.ljwx.platform.app.service;
import com.ljwx.platform.app.appservice.OnlineUserAppService;
import com.ljwx.platform.app.vo.screen.ScreenOverviewVO;
import com.ljwx.platform.app.vo.screen.ScreenRealtimeVO;
import com.ljwx.platform.app.vo.screen.ScreenRecentOperationVO;
import com.ljwx.platform.app.vo.screen.ScreenStatItemVO;
import com.ljwx.platform.app.vo.screen.ScreenTrendItemVO;
import com.ljwx.platform.app.vo.screen.ScreenTrendVO;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Screen service.
*/
@Service
@RequiredArgsConstructor
public class ScreenService {
private static final int OVERVIEW_LIMIT = 5;
private static final int TREND_DAYS = 7;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
private final JdbcTemplate jdbcTemplate;
private final OnlineUserAppService onlineUserAppService;
public ScreenOverviewVO getOverview() {
ScreenOverviewVO overview = new ScreenOverviewVO();
overview.setTotalUsers(queryLong("SELECT COUNT(*) FROM sys_user WHERE deleted = FALSE"));
overview.setTodayUsers(queryLong(
"SELECT COUNT(*) FROM sys_user WHERE deleted = FALSE " +
"AND created_time >= CURRENT_DATE AND created_time < CURRENT_DATE + INTERVAL '1 day'"));
overview.setTotalTenants(queryLong("SELECT COUNT(*) FROM sys_tenant WHERE deleted = FALSE"));
overview.setTodayLoginCount(queryLong(
"SELECT COUNT(*) FROM sys_login_log WHERE deleted = FALSE AND status = 1 " +
"AND login_time >= CURRENT_DATE AND login_time < CURRENT_DATE + INTERVAL '1 day'"));
overview.setTenantUserDistribution(loadTenantUserDistribution());
overview.setRoleDistribution(loadRoleDistribution());
overview.setUserStatusDistribution(loadUserStatusDistribution());
overview.setRecentOperations(loadRecentOperations());
return overview;
}
public ScreenRealtimeVO getRealtime() {
ScreenRealtimeVO realtime = new ScreenRealtimeVO();
realtime.setOnlineUsers((long) onlineUserAppService.listOnlineUsers().size());
realtime.setQps(loadRecentQps());
Runtime runtime = Runtime.getRuntime();
double cpuUsage = 0D;
double memoryUsage = 0D;
java.lang.management.OperatingSystemMXBean osBean =
java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean) {
double cpuLoad = sunOsBean.getCpuLoad();
if (cpuLoad >= 0) {
cpuUsage = round(cpuLoad * 100D);
}
long totalMemory = sunOsBean.getTotalMemorySize();
long freeMemory = sunOsBean.getFreeMemorySize();
if (totalMemory > 0) {
memoryUsage = round((double) (totalMemory - freeMemory) * 100D / totalMemory);
}
} else {
long totalMemory = runtime.maxMemory();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
if (totalMemory > 0) {
memoryUsage = round((double) usedMemory * 100D / totalMemory);
}
}
realtime.setCpuUsage(cpuUsage);
realtime.setMemoryUsage(memoryUsage);
realtime.setTimestamp(OffsetDateTime.now());
return realtime;
}
public ScreenTrendVO getTrend() {
ScreenTrendVO trend = new ScreenTrendVO();
trend.setUserTrend(loadDailyTrend(
"SELECT CAST(created_time AS DATE) AS stat_date, COUNT(*)::BIGINT AS metric_value " +
"FROM sys_user WHERE deleted = FALSE AND created_time >= CURRENT_DATE - INTERVAL '6 days' " +
"GROUP BY CAST(created_time AS DATE)"));
trend.setLoginTrend(loadDailyTrend(
"SELECT CAST(login_time AS DATE) AS stat_date, COUNT(*)::BIGINT AS metric_value " +
"FROM sys_login_log WHERE deleted = FALSE AND status = 1 " +
"AND login_time >= CURRENT_DATE - INTERVAL '6 days' " +
"GROUP BY CAST(login_time AS DATE)"));
return trend;
}
private List<ScreenStatItemVO> loadTenantUserDistribution() {
return jdbcTemplate.query(
"SELECT t.name AS label, COUNT(u.id)::BIGINT AS metric_value " +
"FROM sys_tenant t " +
"LEFT JOIN sys_user u ON u.tenant_id = t.id AND u.deleted = FALSE " +
"WHERE t.deleted = FALSE " +
"GROUP BY t.id, t.name " +
"ORDER BY metric_value DESC, t.id ASC " +
"LIMIT ?",
(rs, rowNum) -> new ScreenStatItemVO(rs.getString("label"), rs.getLong("metric_value")),
OVERVIEW_LIMIT);
}
private List<ScreenStatItemVO> loadRoleDistribution() {
return jdbcTemplate.query(
"SELECT r.name AS label, COUNT(ur.user_id)::BIGINT AS metric_value " +
"FROM sys_role r " +
"LEFT JOIN sys_user_role ur ON ur.role_id = r.id AND ur.deleted = FALSE " +
"WHERE r.deleted = FALSE AND r.status = 1 " +
"GROUP BY r.id, r.name " +
"ORDER BY metric_value DESC, r.id ASC " +
"LIMIT ?",
(rs, rowNum) -> new ScreenStatItemVO(rs.getString("label"), rs.getLong("metric_value")),
OVERVIEW_LIMIT);
}
private List<ScreenStatItemVO> loadUserStatusDistribution() {
long enabledUsers = queryLong("SELECT COUNT(*) FROM sys_user WHERE deleted = FALSE AND status = 1");
long disabledUsers = queryLong("SELECT COUNT(*) FROM sys_user WHERE deleted = FALSE AND status = 0");
long activeUsers = queryLong(
"SELECT COUNT(DISTINCT username) FROM sys_login_log " +
"WHERE deleted = FALSE AND status = 1 AND login_time >= CURRENT_DATE - INTERVAL '29 days'");
long inactiveUsers = Math.max(enabledUsers - activeUsers, 0L);
List<ScreenStatItemVO> distribution = new ArrayList<>();
distribution.add(new ScreenStatItemVO("活跃", activeUsers));
distribution.add(new ScreenStatItemVO("非活跃", inactiveUsers));
distribution.add(new ScreenStatItemVO("禁用", disabledUsers));
return distribution;
}
private List<ScreenRecentOperationVO> loadRecentOperations() {
return jdbcTemplate.query(
"SELECT COALESCE(NULLIF(operator_name, ''), 'system') AS username, " +
"COALESCE(NULLIF(title, ''), request_method || ' ' || request_url) AS action, " +
"created_time AS operation_time " +
"FROM sys_operation_log " +
"WHERE deleted = FALSE " +
"ORDER BY created_time DESC " +
"LIMIT 10",
(rs, rowNum) -> {
ScreenRecentOperationVO operation = new ScreenRecentOperationVO();
operation.setUsername(rs.getString("username"));
operation.setAction(rs.getString("action"));
operation.setTime(rs.getTimestamp("operation_time")
.toLocalDateTime()
.toLocalTime()
.format(TIME_FORMATTER));
return operation;
});
}
private Double loadRecentQps() {
Long requestsLastMinute = queryLong(
"SELECT COUNT(*) FROM sys_operation_log " +
"WHERE deleted = FALSE AND created_time >= CURRENT_TIMESTAMP - INTERVAL '1 minute'");
return round(requestsLastMinute / 60D);
}
private List<ScreenTrendItemVO> loadDailyTrend(String sql) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
Map<LocalDate, Long> values = new HashMap<>();
for (Map<String, Object> row : rows) {
LocalDate date = extractDate(row.get("stat_date"));
if (date != null) {
values.put(date, ((Number) row.get("metric_value")).longValue());
}
}
LocalDate today = LocalDate.now();
List<ScreenTrendItemVO> items = new ArrayList<>(TREND_DAYS);
for (int i = TREND_DAYS - 1; i >= 0; i--) {
LocalDate date = today.minusDays(i);
items.add(new ScreenTrendItemVO(date, values.getOrDefault(date, 0L)));
}
return items;
}
private LocalDate extractDate(Object value) {
if (value instanceof Date date) {
return date.toLocalDate();
}
if (value instanceof Timestamp timestamp) {
return timestamp.toLocalDateTime().toLocalDate();
}
if (value instanceof LocalDate localDate) {
return localDate;
}
return null;
}
private Long queryLong(String sql) {
Long value = jdbcTemplate.queryForObject(sql, Long.class);
return value == null ? 0L : value;
}
private Double round(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}
@@ -0,0 +1,77 @@
package com.ljwx.platform.app.service;
import com.ljwx.platform.security.jwt.JwtProperties;
import com.ljwx.platform.web.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* Encrypts sensitive values using a key derived from the JWT secret.
*/
@Service
@RequiredArgsConstructor
public class SensitiveDataCryptoService {
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH = 128;
private final JwtProperties jwtProperties;
private final SecureRandom secureRandom = new SecureRandom();
public String encrypt(String plainText) {
if (plainText == null || plainText.isBlank()) {
return "";
}
try {
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey(), new GCMParameterSpec(TAG_LENGTH, iv));
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
ByteBuffer buffer = ByteBuffer.allocate(iv.length + cipherText.length);
buffer.put(iv);
buffer.put(cipherText);
return Base64.getEncoder().encodeToString(buffer.array());
} catch (Exception e) {
throw new BusinessException("敏感数据加密失败", e);
}
}
public String decrypt(String encryptedValue) {
if (encryptedValue == null || encryptedValue.isBlank()) {
return "";
}
try {
byte[] payload = Base64.getDecoder().decode(encryptedValue);
ByteBuffer buffer = ByteBuffer.wrap(payload);
byte[] iv = new byte[IV_LENGTH];
buffer.get(iv);
byte[] cipherText = new byte[buffer.remaining()];
buffer.get(cipherText);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey(), new GCMParameterSpec(TAG_LENGTH, iv));
byte[] plainBytes = cipher.doFinal(cipherText);
return new String(plainBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new BusinessException("敏感数据解密失败", e);
}
}
private SecretKeySpec secretKey() throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(digest, "AES");
}
}
@@ -2,6 +2,7 @@ package com.ljwx.platform.app.service;
import com.ljwx.platform.app.dto.WfDefinitionDTO;
import com.ljwx.platform.app.dto.WfInstanceDTO;
import com.ljwx.platform.app.dto.WfInstanceQueryDTO;
import com.ljwx.platform.app.dto.WfTaskDTO;
import com.ljwx.platform.app.infra.mapper.WfDefinitionMapper;
import com.ljwx.platform.app.infra.mapper.WfHistoryMapper;
@@ -17,6 +18,7 @@ import com.ljwx.platform.core.domain.WfTask;
import com.ljwx.platform.web.exception.BusinessException;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.core.result.ErrorCode;
import com.ljwx.platform.core.result.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -121,6 +123,24 @@ public class WorkflowService {
return instance.getId();
}
/**
* 查询流程实例列表。
*/
public PageResult<WfInstanceVO> listInstances(WfInstanceQueryDTO query) {
Map<String, Object> params = new HashMap<>();
params.put("businessKey", query.getBusinessKey());
params.put("businessType", query.getBusinessType());
params.put("status", query.getStatus());
params.put("limit", query.getPageSize());
params.put("offset", (query.getPageNum() - 1) * query.getPageSize());
List<WfInstanceVO> list = instanceMapper.selectList(params).stream()
.map(this::convertToInstanceVO)
.toList();
long total = instanceMapper.countList(params);
return new PageResult<>(list, total);
}
/**
* 查询流程实例详情
*/
@@ -61,7 +61,7 @@ public class CssSanitizer {
}
// 2. 解析 CSS 并验证属性白名单
// 简化实现:使用正则提取 property: value; 对
// 使用属性级正则提取,保留白名单中的声明
Pattern propertyPattern = Pattern.compile("([a-z-]+)\\s*:\\s*([^;]+);");
Matcher matcher = propertyPattern.matcher(css);
@@ -0,0 +1,28 @@
package com.ljwx.platform.app.vo.screen;
import lombok.Data;
import java.util.List;
/**
* Screen overview data.
*/
@Data
public class ScreenOverviewVO {
private Long totalUsers;
private Long todayUsers;
private Long totalTenants;
private Long todayLoginCount;
private List<ScreenStatItemVO> tenantUserDistribution;
private List<ScreenStatItemVO> roleDistribution;
private List<ScreenStatItemVO> userStatusDistribution;
private List<ScreenRecentOperationVO> recentOperations;
}
@@ -0,0 +1,22 @@
package com.ljwx.platform.app.vo.screen;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* Screen realtime data.
*/
@Data
public class ScreenRealtimeVO {
private Long onlineUsers;
private Double qps;
private Double cpuUsage;
private Double memoryUsage;
private OffsetDateTime timestamp;
}
@@ -0,0 +1,16 @@
package com.ljwx.platform.app.vo.screen;
import lombok.Data;
/**
* Recent operation item for the screen page.
*/
@Data
public class ScreenRecentOperationVO {
private String username;
private String action;
private String time;
}
@@ -0,0 +1,18 @@
package com.ljwx.platform.app.vo.screen;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Name/value pair for screen charts.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ScreenStatItemVO {
private String name;
private Long value;
}
@@ -0,0 +1,20 @@
package com.ljwx.platform.app.vo.screen;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* Screen trend point.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ScreenTrendItemVO {
private LocalDate date;
private Long value;
}
@@ -0,0 +1,16 @@
package com.ljwx.platform.app.vo.screen;
import lombok.Data;
import java.util.List;
/**
* Screen trend data.
*/
@Data
public class ScreenTrendVO {
private List<ScreenTrendItemVO> userTrend;
private List<ScreenTrendItemVO> loginTrend;
}
@@ -0,0 +1,24 @@
-- =============================================================
-- V066: Seed Phase-58 permissions for billing, operations dashboard,
-- help docs, and screen APIs. Grant all to admin role (id=1).
-- =============================================================
INSERT INTO sys_permission (id, tenant_id, code, name, created_by, updated_by, version) VALUES
(49, 1, 'system:billing:list', '计量计费查询', 0, 0, 1),
(50, 1, 'system:ops:dashboard', '运营看板查询', 0, 0, 1),
(51, 1, 'system:help:list', '帮助文档查询', 0, 0, 1),
(52, 1, 'system:help:query', '帮助文档详情', 0, 0, 1),
(53, 1, 'system:help:add', '帮助文档新增', 0, 0, 1),
(54, 1, 'system:help:edit', '帮助文档修改', 0, 0, 1),
(55, 1, 'system:help:delete', '帮助文档删除', 0, 0, 1),
(56, 1, 'system:screen:read', '大屏查询(v1)', 0, 0, 1);
INSERT INTO sys_role_permission (id, tenant_id, role_id, permission_id, created_by, updated_by, version) VALUES
(49, 1, 1, 49, 0, 0, 1),
(50, 1, 1, 50, 0, 0, 1),
(51, 1, 1, 51, 0, 0, 1),
(52, 1, 1, 52, 0, 0, 1),
(53, 1, 1, 53, 0, 0, 1),
(54, 1, 1, 54, 0, 0, 1),
(55, 1, 1, 55, 0, 0, 1),
(56, 1, 1, 56, 0, 0, 1);
@@ -39,91 +39,73 @@
<select id="countActiveTenants" resultType="java.lang.Long">
SELECT COUNT(DISTINCT tenant_id)
FROM bill_usage_record
WHERE metric_type = 'LOGIN_COUNT'
AND record_date >= CURRENT_DATE - INTERVAL '#{recentDays} days'
FROM sys_login_log
WHERE status = 1
AND login_time >= CURRENT_DATE - (#{recentDays} - 1) * INTERVAL '1 day'
AND deleted = FALSE
</select>
<select id="getDailyActiveUsers" resultType="com.ljwx.platform.app.vo.billing.DailyStatVO">
SELECT
record_date AS date,
SUM(usage_value)::BIGINT AS count
FROM bill_usage_record
WHERE metric_type = 'LOGIN_COUNT'
AND record_date >= CURRENT_DATE - INTERVAL '#{recentDays} days'
CAST(login_time AS DATE) AS date,
COUNT(DISTINCT username)::BIGINT AS count
FROM sys_login_log
WHERE status = 1
AND login_time >= CURRENT_DATE - (#{recentDays} - 1) * INTERVAL '1 day'
AND deleted = FALSE
GROUP BY record_date
ORDER BY record_date DESC
GROUP BY CAST(login_time AS DATE)
ORDER BY CAST(login_time AS DATE) DESC
</select>
<select id="sumStorageMb" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(usage_value), 0)
FROM (
SELECT DISTINCT ON (tenant_id) usage_value
FROM bill_usage_record
WHERE metric_type = 'STORAGE_MB'
AND deleted = FALSE
ORDER BY tenant_id, record_date DESC
) latest_storage
SELECT COALESCE(ROUND(SUM(file_size)::numeric / 1048576, 2), 0)
FROM sys_file
WHERE deleted = FALSE
</select>
<select id="sumApiCallsToday" resultType="java.lang.Long">
SELECT COALESCE(SUM(usage_value)::BIGINT, 0)
FROM bill_usage_record
WHERE metric_type = 'API_CALLS'
AND record_date = CURRENT_DATE
SELECT COUNT(*)::BIGINT
FROM sys_operation_log
WHERE created_time >= CURRENT_DATE
AND created_time &lt; CURRENT_DATE + INTERVAL '1 day'
AND deleted = FALSE
</select>
<select id="getTenantUsageSummaryList" resultType="com.ljwx.platform.app.vo.billing.TenantUsageSummaryVO">
SELECT
t.id AS tenantId,
t.tenant_name AS tenantName,
t.expire_time AS expireTime,
t.name AS tenantName,
NULL::TIMESTAMP AS expireTime,
COALESCE(u.user_count, 0) AS userCount,
COALESCE(s.storage_mb, 0) AS storageMb,
COALESCE(a.api_calls_total, 0) AS apiCallsTotal,
COALESCE(l.login_count_last30d, 0) AS loginCountLast30d,
CASE WHEN t.expire_time IS NOT NULL AND t.expire_time &lt;= CURRENT_TIMESTAMP + INTERVAL '30 days' THEN TRUE ELSE FALSE END AS isExpiringSoon
FALSE AS isExpiringSoon
FROM sys_tenant t
LEFT JOIN (
SELECT tenant_id, usage_value AS user_count
FROM bill_usage_record
WHERE metric_type = 'USER_COUNT'
AND deleted = FALSE
AND (tenant_id, record_date) IN (
SELECT tenant_id, MAX(record_date)
FROM bill_usage_record
WHERE metric_type = 'USER_COUNT' AND deleted = FALSE
GROUP BY tenant_id
)
SELECT tenant_id, COUNT(*)::BIGINT AS user_count
FROM sys_user
WHERE deleted = FALSE
GROUP BY tenant_id
) u ON t.id = u.tenant_id
LEFT JOIN (
SELECT tenant_id, usage_value AS storage_mb
FROM bill_usage_record
WHERE metric_type = 'STORAGE_MB'
AND deleted = FALSE
AND (tenant_id, record_date) IN (
SELECT tenant_id, MAX(record_date)
FROM bill_usage_record
WHERE metric_type = 'STORAGE_MB' AND deleted = FALSE
GROUP BY tenant_id
)
SELECT tenant_id, COALESCE(ROUND(SUM(file_size)::numeric / 1048576, 2), 0) AS storage_mb
FROM sys_file
WHERE deleted = FALSE
GROUP BY tenant_id
) s ON t.id = s.tenant_id
LEFT JOIN (
SELECT tenant_id, SUM(usage_value)::BIGINT AS api_calls_total
FROM bill_usage_record
WHERE metric_type = 'API_CALLS'
AND record_date >= CURRENT_DATE - INTERVAL '30 days'
SELECT tenant_id, COUNT(*)::BIGINT AS api_calls_total
FROM sys_operation_log
WHERE created_time >= CURRENT_DATE - INTERVAL '29 days'
AND deleted = FALSE
GROUP BY tenant_id
) a ON t.id = a.tenant_id
LEFT JOIN (
SELECT tenant_id, SUM(usage_value)::BIGINT AS login_count_last30d
FROM bill_usage_record
WHERE metric_type = 'LOGIN_COUNT'
AND record_date >= CURRENT_DATE - INTERVAL '30 days'
SELECT tenant_id, COUNT(*)::BIGINT AS login_count_last30d
FROM sys_login_log
WHERE status = 1
AND login_time >= CURRENT_DATE - INTERVAL '29 days'
AND deleted = FALSE
GROUP BY tenant_id
) l ON t.id = l.tenant_id
@@ -101,4 +101,17 @@
</if>
</select>
<update id="deleteById">
UPDATE sys_operation_log
SET deleted = TRUE
WHERE id = #{id}
AND deleted = FALSE
</update>
<update id="cleanAll">
UPDATE sys_operation_log
SET deleted = TRUE
WHERE deleted = FALSE
</update>
</mapper>
@@ -50,6 +50,7 @@
WHERE deleted = FALSE
<if test="definitionId != null">AND definition_id = #{definitionId}</if>
<if test="businessKey != null">AND business_key = #{businessKey}</if>
<if test="businessType != null">AND business_type = #{businessType}</if>
<if test="status != null">AND status = #{status}</if>
ORDER BY created_time DESC
LIMIT #{limit} OFFSET #{offset}
@@ -60,6 +61,7 @@
WHERE deleted = FALSE
<if test="definitionId != null">AND definition_id = #{definitionId}</if>
<if test="businessKey != null">AND business_key = #{businessKey}</if>
<if test="businessType != null">AND business_type = #{businessType}</if>
<if test="status != null">AND status = #{status}</if>
</select>
@@ -2,6 +2,7 @@ package com.ljwx.platform.data.interceptor;
import com.ljwx.platform.core.context.CurrentTenantHolder;
import com.ljwx.platform.core.context.CurrentUserHolder;
import com.ljwx.platform.core.id.SnowflakeIdGenerator;
import com.ljwx.platform.data.annotation.AuditChange;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,12 +13,11 @@ import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.sql.Connection;
@@ -48,6 +48,8 @@ public class DataChangeInterceptor implements Interceptor {
private final ObjectProvider<CurrentTenantHolder> tenantHolderProvider;
private final ObjectProvider<CurrentUserHolder> userHolderProvider;
private final ObjectProvider<DataSource> dataSourceProvider;
private final ObjectProvider<SnowflakeIdGenerator> idGeneratorProvider;
@Override
public Object intercept(Invocation invocation) throws Throwable {
@@ -95,13 +97,18 @@ public class DataChangeInterceptor implements Interceptor {
);
}
Long tenantId = resolveTenantId();
Long userId = resolveUserId();
// 异步记录变更
recordChangesAsync(
annotation.tableName(),
recordId,
oldData,
newData,
sqlType == SqlCommandType.DELETE ? "DELETE" : "UPDATE"
sqlType == SqlCommandType.DELETE ? "DELETE" : "UPDATE",
tenantId,
userId
);
return result;
@@ -165,7 +172,6 @@ public class DataChangeInterceptor implements Interceptor {
) {
try {
Executor executor = (Executor) invocation.getTarget();
Configuration config = ((MappedStatement) invocation.getArgs()[0]).getConfiguration();
// 使用原生 JDBC 查询(避免触发其他拦截器)
Connection conn = executor.getTransaction().getConnection();
@@ -201,11 +207,11 @@ public class DataChangeInterceptor implements Interceptor {
Long recordId,
Map<String, Object> oldData,
Map<String, Object> newData,
String operateType
String operateType,
Long tenantId,
Long userId
) {
try {
Long tenantId = resolveTenantId();
Long userId = resolveUserId();
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> changes = new ArrayList<>();
@@ -217,7 +223,7 @@ public class DataChangeInterceptor implements Interceptor {
change.put("table_name", tableName);
change.put("record_id", recordId);
change.put("field_name", entry.getKey());
change.put("old_value", String.valueOf(entry.getValue()));
change.put("old_value", stringifyValue(entry.getValue()));
change.put("new_value", "");
change.put("operate_type", "DELETE");
change.put("tenant_id", tenantId);
@@ -237,8 +243,8 @@ public class DataChangeInterceptor implements Interceptor {
change.put("table_name", tableName);
change.put("record_id", recordId);
change.put("field_name", fieldName);
change.put("old_value", String.valueOf(oldValue));
change.put("new_value", String.valueOf(newValue));
change.put("old_value", stringifyValue(oldValue));
change.put("new_value", stringifyValue(newValue));
change.put("operate_type", "UPDATE");
change.put("tenant_id", tenantId);
change.put("created_by", userId);
@@ -249,7 +255,7 @@ public class DataChangeInterceptor implements Interceptor {
}
if (!changes.isEmpty()) {
// TODO: 实际写入数据库(需要注入 SysDataChangeLogMapper)
persistChanges(changes, now, userId);
log.info("Recorded {} changes for {}.{}", changes.size(), tableName, recordId);
}
} catch (Exception e) {
@@ -257,6 +263,46 @@ public class DataChangeInterceptor implements Interceptor {
}
}
private void persistChanges(List<Map<String, Object>> changes, LocalDateTime now, Long userId) throws Exception {
DataSource dataSource = dataSourceProvider.getIfAvailable();
SnowflakeIdGenerator idGenerator = idGeneratorProvider.getIfAvailable();
if (dataSource == null || idGenerator == null) {
log.warn("Data change audit skipped because datasource or id generator is unavailable");
return;
}
String insertSql = "INSERT INTO sys_data_change_log (" +
"id, table_name, record_id, field_name, old_value, new_value, operate_type, " +
"tenant_id, created_by, created_time, updated_by, updated_time, deleted, version" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
try (Connection connection = dataSource.getConnection();
var statement = connection.prepareStatement(insertSql)) {
for (Map<String, Object> change : changes) {
statement.setLong(1, idGenerator.nextId());
statement.setString(2, (String) change.get("table_name"));
statement.setLong(3, (Long) change.get("record_id"));
statement.setString(4, (String) change.get("field_name"));
statement.setString(5, (String) change.get("old_value"));
statement.setString(6, (String) change.get("new_value"));
statement.setString(7, (String) change.get("operate_type"));
statement.setLong(8, (Long) change.get("tenant_id"));
statement.setLong(9, (Long) change.get("created_by"));
statement.setObject(10, change.get("created_time"));
statement.setLong(11, userId);
statement.setObject(12, now);
statement.setBoolean(13, false);
statement.setInt(14, 1);
statement.addBatch();
}
statement.executeBatch();
}
}
private String stringifyValue(Object value) {
return value == null ? "" : String.valueOf(value);
}
private Long resolveTenantId() {
try {
CurrentTenantHolder holder = tenantHolderProvider.getIfAvailable();
+2 -2
View File
@@ -2,9 +2,9 @@ import request from './request'
import type { Result, UserVO } from '@ljwx/shared'
export function getUserProfile(): Promise<Result<UserVO>> {
return request.get('/api/v1/users/profile')
return request.get('/api/v1/profile')
}
export function updateUserProfile(data: { nickname?: string; avatar?: string }): Promise<Result<void>> {
return request.put('/api/v1/users/profile', data)
return request.put('/api/v1/profile', data)
}
+54 -53
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { getScreenOverview, getScreenRealtime, getScreenTrend } from '@/api/screen'
import type { ScreenOverviewVO, ScreenRealtimeVO, ScreenTrendVO } from '@ljwx/shared'
import BarChart from '@/components/charts/BarChart.vue'
@@ -17,48 +17,60 @@ const loading = ref(false)
let realtimeTimer: ReturnType<typeof setInterval> | null = null
// ── Derived chart data ──────────────────────────────────────────────────────
const trendXData = computed(() => trend.value?.userTrend.map((item) => item.date) ?? [])
const userTrendData = computed(() => trend.value?.userTrend.map((item) => item.value) ?? [])
const loginTrendData = computed(() => trend.value?.loginTrend.map((item) => item.value) ?? [])
const trendXData = ref<string[]>([])
const userTrendData = ref<number[]>([])
const loginTrendData = ref<number[]>([])
const roleDistData = computed(() => overview.value?.roleDistribution ?? [])
const roleDistData = ref([
{ name: '管理员', value: 12 },
{ name: '普通用户', value: 88 },
{ name: '访客', value: 24 },
])
const tenantBarData = ref({
xData: ['租户A', '租户B', '租户C', '租户D', '租户E'],
series: [{ name: '用户数', data: [120, 85, 200, 60, 145] }],
const tenantBarData = computed(() => {
const distribution = overview.value?.tenantUserDistribution ?? []
return {
xData: distribution.map((item) => item.name),
series: [{ name: '用户数', data: distribution.map((item) => item.value) }],
}
})
const userStatusData = computed(() => overview.value?.userStatusDistribution ?? [])
const scrollColumns = [
{ key: 'username', label: '用户名', width: '120px' },
{ key: 'action', label: '操作', width: '100px' },
{ key: 'action', label: '操作', width: '180px' },
{ key: 'time', label: '时间' },
]
const scrollData = ref([
{ username: 'admin', action: '登录', time: '10:01:23' },
{ username: 'user01', action: '查询', time: '10:02:11' },
{ username: 'user02', action: '新增', time: '10:03:05' },
{ username: 'user03', action: '修改', time: '10:04:44' },
{ username: 'user04', action: '删除', time: '10:05:30' },
{ username: 'user05', action: '导出', time: '10:06:18' },
{ username: 'user06', action: '登录', time: '10:07:02' },
])
const scrollData = computed(() => overview.value?.recentOperations ?? [])
const healthConfig = computed(() => {
const metrics = [realtime.value?.cpuUsage ?? 0, realtime.value?.memoryUsage ?? 0]
let normal = 0
let warning = 0
let abnormal = 0
metrics.forEach((metric) => {
if (metric >= 90) {
abnormal += 1
} else if (metric >= 75) {
warning += 1
} else {
normal += 1
}
})
return {
data: [
{ name: '正常', value: normal },
{ name: '警告', value: warning },
{ name: '异常', value: abnormal },
],
radius: '60%',
}
})
// ── Data fetching ───────────────────────────────────────────────────────────
async function fetchOverview() {
loading.value = true
try {
overview.value = await getScreenOverview()
} finally {
loading.value = false
}
overview.value = await getScreenOverview()
}
async function fetchRealtime() {
@@ -72,18 +84,22 @@ async function fetchRealtime() {
async function fetchTrend() {
try {
trend.value = await getScreenTrend()
if (trend.value) {
trendXData.value = trend.value.userTrend.map((t) => t.date)
userTrendData.value = trend.value.userTrend.map((t) => t.value)
loginTrendData.value = trend.value.loginTrend.map((t) => t.value)
}
} catch {
// silently ignore
}
}
async function loadScreen() {
loading.value = true
try {
await Promise.all([fetchOverview(), fetchRealtime(), fetchTrend()])
} finally {
loading.value = false
}
}
onMounted(async () => {
await Promise.all([fetchOverview(), fetchRealtime(), fetchTrend()])
await loadScreen()
realtimeTimer = setInterval(fetchRealtime, 5000)
})
@@ -222,14 +238,7 @@ onUnmounted(() => {
<!-- Pie chart -->
<dv-border-box-8 class="panel">
<div class="panel-title">用户状态分布</div>
<PieChart
:data="[
{ name: '活跃', value: 68 },
{ name: '非活跃', value: 22 },
{ name: '禁用', value: 10 },
]"
height="200px"
/>
<PieChart :data="userStatusData" height="200px" />
</dv-border-box-8>
<!-- Scroll table -->
@@ -246,14 +255,7 @@ onUnmounted(() => {
<dv-border-box-8 class="panel">
<div class="panel-title">系统状态</div>
<dv-active-ring-chart
:config="{
data: [
{ name: '正常', value: 95 },
{ name: '警告', value: 4 },
{ name: '异常', value: 1 },
],
radius: '60%',
}"
:config="healthConfig"
style="height: 180px"
/>
</dv-border-box-8>
@@ -415,4 +417,3 @@ onUnmounted(() => {
padding: 0 40px;
}
</style>
+20 -15
View File
@@ -1,21 +1,26 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3002,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8080'
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: 3002,
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
},
},
}
})
+2
View File
@@ -57,6 +57,8 @@ export type {
NoticeUpdateDTO,
ScreenOverviewVO,
ScreenRealtimeVO,
ScreenStatItem,
ScreenRecentOperation,
ScreenTrendItem,
ScreenTrendVO,
MenuVO,
+19 -1
View File
@@ -375,6 +375,14 @@ export interface ScreenOverviewVO {
totalTenants: number
/** 今日登录次数 */
todayLoginCount: number
/** 租户用户分布 */
tenantUserDistribution: ScreenStatItem[]
/** 角色分布 */
roleDistribution: ScreenStatItem[]
/** 用户状态分布 */
userStatusDistribution: ScreenStatItem[]
/** 最近操作日志 */
recentOperations: ScreenRecentOperation[]
}
export interface ScreenRealtimeVO {
@@ -395,6 +403,17 @@ export interface ScreenTrendItem {
value: number
}
export interface ScreenStatItem {
name: string
value: number
}
export interface ScreenRecentOperation {
username: string
action: string
time: string
}
export interface ScreenTrendVO {
/** 用户增长趋势(近 7 天) */
userTrend: ScreenTrendItem[]
@@ -449,4 +468,3 @@ export interface TaskLogStatsVO {
/** 最小耗时(毫秒) */
minDuration: number
}
+2
View File
@@ -52,6 +52,8 @@ export type {
NoticeUpdateDTO,
ScreenOverviewVO,
ScreenRealtimeVO,
ScreenStatItem,
ScreenRecentOperation,
ScreenTrendItem,
ScreenTrendVO,
MenuVO,
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# gate-admin-route-coverage.sh — fail when admin page views are not wired into router
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$PROJECT_ROOT"
ROUTER_FILE="ljwx-platform-admin/src/router/index.ts"
ALLOWLIST_FILE="scripts/gates/admin-route-allowlist.txt"
if [[ ! -f "$ROUTER_FILE" ]]; then
echo " gate-admin-route-coverage: SKIPPED (router file not found)"
exit 0
fi
python3 - "$ROUTER_FILE" "$ALLOWLIST_FILE" <<'PY'
import pathlib
import re
import sys
router_file = pathlib.Path(sys.argv[1])
allowlist_file = pathlib.Path(sys.argv[2])
views_root = pathlib.Path("ljwx-platform-admin/src/views")
allowlist = set()
if allowlist_file.exists():
for line in allowlist_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
allowlist.add(line)
router_text = router_file.read_text(encoding="utf-8")
routed = {
match.replace("@/views/", "ljwx-platform-admin/src/views/")
for match in re.findall(r"@/views/[^'\"`]+\.vue", router_text)
}
candidates = []
for file in sorted(views_root.rglob("*.vue")):
rel = file.as_posix()
if "/components/" in rel:
continue
if rel in allowlist:
continue
candidates.append(rel)
orphans = [path for path in candidates if path not in routed]
print(f"[Admin Route Coverage] routed={len(routed)} candidates={len(candidates)} orphans={len(orphans)}")
if orphans:
for path in orphans:
print(f" FAIL: orphan admin page view — {path}")
print("")
print("════════════════════════════════════════════════════")
print(f" gate-admin-route-coverage: ERRORS={len(orphans)}")
print(" gate-admin-route-coverage: FAILED")
sys.exit(1)
print("")
print("════════════════════════════════════════════════════")
print(" gate-admin-route-coverage: ERRORS=0")
print(" gate-admin-route-coverage: PASSED")
PY
+3
View File
@@ -183,6 +183,9 @@ fi
echo ""
run_rule "R11" "Baseline" "scripts/gates/gate-baseline.sh" "false"
run_rule "R12" "No Placeholder" "scripts/gates/gate-no-placeholder.sh" "false"
run_rule "R13" "Frontend API Contract" "scripts/gates/gate-frontend-api-contract.sh" "false"
run_rule "R14" "Admin Route Coverage" "scripts/gates/gate-admin-route-coverage.sh" "false"
if [[ -x "scripts/reports/collect-artifacts.sh" ]]; then
bash scripts/reports/collect-artifacts.sh "$PHASE" >/dev/null 2>&1 || true
+169 -70
View File
@@ -8,12 +8,15 @@
# 3. 所有 API 路径都有对应的 operationId
# 4. 仅在 app 模块存在且有 Controller 时执行
# ═══════════════════════════════════════════════════════════
set -uo pipefail
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$PROJECT_ROOT"
ERRORS=0
OPENAPI_BASE_URL="${OPENAPI_BASE_URL:-}"
OPENAPI_FILE="${OPENAPI_FILE:-}"
SYNC_OPENAPI_DOCS="${SYNC_OPENAPI_DOCS:-0}"
# ── Check: app module with controllers? ──
APP_DIR="ljwx-platform-app"
@@ -28,67 +31,66 @@ if [[ "$CONTROLLER_COUNT" -eq 0 ]]; then
exit 0
fi
# ── Generate OpenAPI spec ──
# Method 1: Use springdoc-openapi-maven-plugin if configured
OPENAPI_OUTPUT="target/openapi.json"
mkdir -p "$(dirname "$OPENAPI_OUTPUT")"
echo "[Contract] Generating OpenAPI spec"
if grep -q 'springdoc-openapi-maven-plugin' pom.xml 2>/dev/null || \
grep -rq 'springdoc-openapi-maven-plugin' "$APP_DIR/pom.xml" 2>/dev/null; then
# Plugin-based generation
if mvn verify -f pom.xml -B -DskipTests \
-Dspringdoc.writer-with-order-by-keys=true 2>&1; then
echo " OpenAPI spec generated via Maven plugin"
if [[ -n "$OPENAPI_FILE" ]]; then
if [[ -f "$OPENAPI_FILE" && -s "$OPENAPI_FILE" ]]; then
cp "$OPENAPI_FILE" "$OPENAPI_OUTPUT"
echo " OpenAPI spec loaded from $OPENAPI_FILE"
else
echo " WARN: Maven plugin generation failed, trying runtime generation"
echo " FAIL: OPENAPI_FILE not found or empty — $OPENAPI_FILE"
ERRORS=$((ERRORS + 1))
fi
fi
# Method 2: Start app briefly and fetch from /v3/api-docs
if [[ ! -s "$OPENAPI_OUTPUT" ]]; then
echo " Attempting runtime OpenAPI generation"
# Ensure DB is up (using local PostgreSQL)
echo " Checking local PostgreSQL connection"
if command -v psql > /dev/null 2>&1; then
if ! psql -U postgres -d ljwx_platform -c "SELECT 1" > /dev/null 2>&1; then
echo " WARNING: Cannot connect to local PostgreSQL"
elif [[ -n "$OPENAPI_BASE_URL" ]]; then
if curl -fsS --retry 5 --retry-delay 1 "${OPENAPI_BASE_URL%/}/v3/api-docs" > "$OPENAPI_OUTPUT"; then
echo " OpenAPI spec fetched from $OPENAPI_BASE_URL"
else
echo " FAIL: Could not fetch OpenAPI spec from $OPENAPI_BASE_URL"
ERRORS=$((ERRORS + 1))
fi
else
if grep -q 'springdoc-openapi-maven-plugin' pom.xml 2>/dev/null || \
grep -rq 'springdoc-openapi-maven-plugin' "$APP_DIR/pom.xml" 2>/dev/null; then
if mvn verify -f pom.xml -B -DskipTests \
-Dspringdoc.writer-with-order-by-keys=true 2>&1; then
echo " OpenAPI spec generated via Maven plugin"
else
echo " WARN: Maven plugin generation failed, trying runtime generation"
fi
fi
if [[ ! -s "$OPENAPI_OUTPUT" ]]; then
echo " Attempting runtime OpenAPI generation"
mvn package -f pom.xml -B -DskipTests -q 2>/dev/null
APP_JAR=$(find "$APP_DIR/target" -name '*.jar' ! -name '*-sources.jar' 2>/dev/null | head -1)
# Build and start app in background
mvn package -f pom.xml -B -DskipTests -q 2>/dev/null
APP_JAR=$(find "$APP_DIR/target" -name '*.jar' ! -name '*-sources.jar' 2>/dev/null | head -1)
if [[ -n "$APP_JAR" && -f "$APP_JAR" ]]; then
java -jar "$APP_JAR" \
--server.port=18080 \
--cache.enabled=false \
--spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration &
APP_PID=$!
if [[ -n "$APP_JAR" && -f "$APP_JAR" ]]; then
# Runtime OpenAPI extraction should not depend on local Redis auth state.
java -jar "$APP_JAR" \
--server.port=18080 \
--cache.enabled=false \
--spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration &
APP_PID=$!
for i in $(seq 1 60); do
if curl -sf http://localhost:18080/v3/api-docs > "$OPENAPI_OUTPUT" 2>/dev/null; then
echo " OpenAPI spec fetched from running application"
break
fi
sleep 1
done
# Wait for startup (max 60s)
for i in $(seq 1 60); do
if curl -sf http://localhost:18080/v3/api-docs > "$OPENAPI_OUTPUT" 2>/dev/null; then
echo " OpenAPI spec fetched from running application"
break
fi
sleep 1
done
# Cleanup
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
fi
fi
fi
# ── Validate generated spec ──
if [[ ! -f "$OPENAPI_OUTPUT" || ! -s "$OPENAPI_OUTPUT" ]]; then
echo " WARN: Could not generate OpenAPI spec — skipping contract validation"
echo " gate-contract: SKIPPED"
exit 0
echo " FAIL: Could not generate OpenAPI spec"
exit 1
fi
echo ""
@@ -98,43 +100,140 @@ echo "[Contract] Validating OpenAPI spec"
if ! python3 -c "import json; json.load(open('$OPENAPI_OUTPUT'))" 2>/dev/null && \
! jq empty "$OPENAPI_OUTPUT" 2>/dev/null; then
echo " FAIL: $OPENAPI_OUTPUT is not valid JSON"
((ERRORS++))
ERRORS=$((ERRORS + 1))
fi
# Check: expected API paths from spec/03-api.md
MISSING_OPERATION_IDS="$(jq -r '
.paths
| to_entries[]
| .key as $path
| .value
| to_entries[]
| select(.value.operationId == null or .value.operationId == "")
| "\(.key | ascii_upcase) \($path)"
' "$OPENAPI_OUTPUT" 2>/dev/null || true)"
if [[ -n "$MISSING_OPERATION_IDS" ]]; then
while IFS= read -r hit; do
[[ -z "$hit" ]] && continue
echo " FAIL: Missing operationId for $hit"
ERRORS=$((ERRORS + 1))
done <<< "$MISSING_OPERATION_IDS"
fi
# Check: expected API routes from spec/03-api.md
if [[ -f "spec/03-api.md" ]]; then
echo "[Contract] Cross-checking with spec/03-api.md"
# Extract expected paths from api spec (lines like "POST /api/v1/auth/login")
EXPECTED_PATHS=$(grep -oE '(GET|POST|PUT|DELETE|PATCH)[[:space:]]+/api/[^[:space:]]+' spec/03-api.md \
| awk '{print $2}' | sort -u || true)
EXPECTED_ROUTES="$(
python3 - "spec/03-api.md" <<'PY'
import pathlib
import re
import sys
if [[ -n "$EXPECTED_PATHS" ]]; then
# Extract actual paths from OpenAPI JSON
ACTUAL_PATHS=$(jq -r '.paths | keys[]' "$OPENAPI_OUTPUT" 2>/dev/null | sort -u || true)
text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
routes = set()
MISSING_COUNT=0
while IFS= read -r expected; do
[[ -z "$expected" ]] && continue
# Normalize path parameters: /api/v1/users/{id} → match with regex
PATTERN=$(echo "$expected" | sed 's/{[^}]*}/[^\/]*/g')
if ! echo "$ACTUAL_PATHS" | grep -qE "^${PATTERN}$"; then
echo " WARN: Expected path $expected not found in generated spec"
((MISSING_COUNT++))
table_pattern = re.compile(r"\|\s*(GET|POST|PUT|DELETE|PATCH)\s*\|\s*(/api/[^\s|]+)\s*\|", re.IGNORECASE)
inline_pattern = re.compile(r"\b(GET|POST|PUT|DELETE|PATCH)\s+(/api/\S+)", re.IGNORECASE)
for method, path in table_pattern.findall(text):
routes.add((method.upper(), path))
for method, path in inline_pattern.findall(text):
routes.add((method.upper(), path.rstrip("`|),.")))
for method, path in sorted(routes):
print(f"{method}\t{path}")
PY
)"
if [[ -n "$EXPECTED_ROUTES" ]]; then
SPEC_ERRORS=0
while IFS=$'\t' read -r expected_method expected_path; do
[[ -z "$expected_method" || -z "$expected_path" ]] && continue
canonical_expected="$(echo "$expected_path" | sed -E 's/\{[^}]+\}/{param}/g')"
method_key="$(echo "$expected_method" | tr '[:upper:]' '[:lower:]')"
matched_path="$(
jq -r \
--arg expected_path "$expected_path" \
--arg canonical_expected "$canonical_expected" \
--arg method "$method_key" '
if .paths[$expected_path] != null then
$expected_path
else
(
[
.paths
| to_entries[]
| select((.key | gsub("\\{[^}]+\\}"; "{param}")) == $canonical_expected and .value[$method] != null)
| .key
][0]
) // (
[
.paths
| keys[]
| select((gsub("\\{[^}]+\\}"; "{param}")) == $canonical_expected)
][0]
) // ""
end
' "$OPENAPI_OUTPUT" 2>/dev/null
)"
if [[ -z "$matched_path" ]]; then
echo " FAIL: Expected route $expected_method $expected_path not found in generated spec"
SPEC_ERRORS=$((SPEC_ERRORS + 1))
continue
fi
done <<< "$EXPECTED_PATHS"
if [[ $MISSING_COUNT -gt 0 ]]; then
echo " WARNING: $MISSING_COUNT expected paths not found in OpenAPI spec"
# This is a warning, not an error — paths may not be implemented yet
if ! jq -e --arg path "$matched_path" --arg method "$method_key" '.paths[$path][$method] != null' "$OPENAPI_OUTPUT" >/dev/null 2>&1; then
actual_methods="$(jq -r --arg path "$matched_path" '.paths[$path] | keys | map(ascii_upcase) | join(", ")' "$OPENAPI_OUTPUT" 2>/dev/null || true)"
echo " FAIL: Expected route $expected_method $expected_path not exported (available methods: ${actual_methods:-none})"
SPEC_ERRORS=$((SPEC_ERRORS + 1))
fi
done <<< "$EXPECTED_ROUTES"
if [[ $SPEC_ERRORS -gt 0 ]]; then
echo " FAIL: $SPEC_ERRORS spec routes not satisfied by generated OpenAPI"
ERRORS=$((ERRORS + SPEC_ERRORS))
else
echo " PASS: All expected API paths found"
echo " PASS: All expected API routes found"
fi
fi
fi
# ── Copy to docs for reference ──
mkdir -p docs/api
cp "$OPENAPI_OUTPUT" docs/api/openapi.json 2>/dev/null || true
if [[ -f "docs/api/openapi.json" ]]; then
ACTUAL_PATHS_FILE="$(mktemp)"
REPO_PATHS_FILE="$(mktemp)"
jq -r '.paths | keys[]' "$OPENAPI_OUTPUT" | sort -u >"$ACTUAL_PATHS_FILE"
jq -r '.paths | keys[]' "docs/api/openapi.json" | sort -u >"$REPO_PATHS_FILE"
RUNTIME_ONLY="$(comm -23 "$ACTUAL_PATHS_FILE" "$REPO_PATHS_FILE" || true)"
REPO_ONLY="$(comm -13 "$ACTUAL_PATHS_FILE" "$REPO_PATHS_FILE" || true)"
if [[ -n "$RUNTIME_ONLY" || -n "$REPO_ONLY" ]]; then
echo "[Contract] Checking docs/api/openapi.json drift"
if [[ -n "$RUNTIME_ONLY" ]]; then
while IFS= read -r path; do
[[ -z "$path" ]] && continue
echo " FAIL: runtime-only path missing from docs/api/openapi.json — $path"
ERRORS=$((ERRORS + 1))
done <<< "$RUNTIME_ONLY"
fi
if [[ -n "$REPO_ONLY" ]]; then
while IFS= read -r path; do
[[ -z "$path" ]] && continue
echo " FAIL: stale docs/api/openapi.json path not exported at runtime — $path"
ERRORS=$((ERRORS + 1))
done <<< "$REPO_ONLY"
fi
fi
fi
if [[ "$SYNC_OPENAPI_DOCS" == "1" ]]; then
mkdir -p docs/api
cp "$OPENAPI_OUTPUT" docs/api/openapi.json
echo " Synced docs/api/openapi.json from generated spec"
fi
# ── Summary ──
echo ""
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# gate-frontend-api-contract.sh — ensure admin/screen/mobile API clients only call exported OpenAPI paths
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$PROJECT_ROOT"
OPENAPI_FILE="${OPENAPI_FILE:-target/openapi.json}"
ALLOWLIST_FILE="scripts/gates/frontend-api-allowlist.txt"
if [[ ! -f "$OPENAPI_FILE" || ! -s "$OPENAPI_FILE" ]]; then
OPENAPI_FILE="docs/api/openapi.json"
fi
if [[ ! -f "$OPENAPI_FILE" || ! -s "$OPENAPI_FILE" ]]; then
echo " FAIL: OpenAPI file not found. Run gate-contract first." >&2
exit 1
fi
echo "[Frontend API Contract] Using $OPENAPI_FILE"
python3 - "$OPENAPI_FILE" "$ALLOWLIST_FILE" <<'PY'
import json
import pathlib
import re
import sys
openapi_file = pathlib.Path(sys.argv[1])
allowlist_file = pathlib.Path(sys.argv[2])
with open(openapi_file, "r", encoding="utf-8") as fh:
spec = json.load(fh)
openapi_methods = {}
for path, methods in spec.get("paths", {}).items():
canonical = re.sub(r"\{[^}]+\}", "{param}", path)
openapi_methods.setdefault(canonical, set()).update(m.lower() for m in methods.keys())
allowlist = set()
if allowlist_file.exists():
for line in allowlist_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
allowlist.add(line)
roots = [
pathlib.Path("ljwx-platform-admin/src/api"),
pathlib.Path("ljwx-platform-screen/src/api"),
pathlib.Path("ljwx-platform-mobile/src/api"),
]
call_re = re.compile(
r"request\.(get|post|put|delete|patch)(?:<[^>]+>)?\(\s*(?:`([^`]+)`|'([^']+)'|\"([^\"]+)\")"
)
errors = []
for root in roots:
if not root.exists():
continue
for file in sorted(root.rglob("*.ts")):
for lineno, line in enumerate(file.read_text(encoding="utf-8").splitlines(), start=1):
for match in call_re.finditer(line):
method = match.group(1).lower()
path = next(group for group in match.groups()[1:] if group is not None)
if not path.startswith("/api/"):
continue
canonical = re.sub(r"\$\{[^}]+\}", "{param}", path)
allow_key = f"{method} {canonical}"
if allow_key in allowlist or canonical in allowlist:
continue
supported = openapi_methods.get(canonical)
if supported is None:
errors.append(f"{file}:{lineno} {method.upper()} {path} — path not found in OpenAPI")
elif method not in supported:
errors.append(
f"{file}:{lineno} {method.upper()} {path} — path exists but method not exported ({sorted(supported)})"
)
if errors:
for issue in errors:
print(f" FAIL: {issue}")
print("")
print("════════════════════════════════════════════════════")
print(f" gate-frontend-api-contract: ERRORS={len(errors)}")
print(" gate-frontend-api-contract: FAILED")
sys.exit(1)
print("")
print("════════════════════════════════════════════════════")
print(" gate-frontend-api-contract: ERRORS=0")
print(" gate-frontend-api-contract: PASSED")
PY
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# gate-no-placeholder.sh — fail on fake-complete implementation markers
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$PROJECT_ROOT"
ERRORS=0
ALLOWLIST_FILE="scripts/gates/placeholder-allowlist.txt"
PATTERN='\bTODO\b|\bFIXME\b|占位(响应|数据|实现)|简化实现|实际应(调用|使用|写入|查询|获取)|Implement actual|Encrypt in production|硬编码 AES 密钥|生产环境应从配置中心获取|example\.com/exports'
TARGETS=(
"ljwx-platform-app/src/main/java"
"ljwx-platform-core/src/main/java"
"ljwx-platform-data/src/main/java"
"ljwx-platform-security/src/main/java"
"ljwx-platform-web/src/main/java"
"ljwx-platform-admin/src"
"ljwx-platform-screen/src"
"ljwx-platform-mobile/src"
"packages/shared/src"
)
GLOBS=(
"--glob=*.java"
"--glob=*.kt"
"--glob=*.js"
"--glob=*.jsx"
"--glob=*.ts"
"--glob=*.tsx"
"--glob=*.vue"
)
if ! command -v rg >/dev/null 2>&1; then
echo " FAIL: rg is required for gate-no-placeholder.sh" >&2
exit 1
fi
echo "[Fake Complete] Scanning for fake-complete markers"
hits="$(
rg -n -S \
"${GLOBS[@]}" \
--glob '!**/node_modules/**' \
--glob '!**/target/**' \
--glob '!**/dist/**' \
--glob '!**/.pnpm/**' \
--glob '!**/auto-imports.d.ts' \
--glob '!**/components.d.ts' \
"$PATTERN" \
"${TARGETS[@]}" 2>/dev/null || true
)"
if [[ -f "$ALLOWLIST_FILE" ]]; then
hits="$(printf '%s\n' "$hits" | grep -F -v -f "$ALLOWLIST_FILE" || true)"
fi
if [[ -n "$hits" ]]; then
while IFS= read -r hit; do
[[ -z "$hit" ]] && continue
echo " FAIL: fake-complete marker found — $hit"
ERRORS=$((ERRORS + 1))
done <<< "$hits"
fi
echo ""
echo "════════════════════════════════════════════════════"
echo " gate-no-placeholder: ERRORS=$ERRORS"
if [[ $ERRORS -gt 0 ]]; then
echo " gate-no-placeholder: FAILED"
exit 1
fi
echo " gate-no-placeholder: PASSED"
+6
View File
@@ -116,6 +116,9 @@ SCRIPT_FILES=(
"scripts/gates/gate-integration.sh"
"scripts/gates/gate-contract.sh"
"scripts/gates/gate-nfr.sh"
"scripts/gates/gate-no-placeholder.sh"
"scripts/gates/gate-frontend-api-contract.sh"
"scripts/gates/gate-admin-route-coverage.sh"
"scripts/hooks/pre-edit-guard.sh"
"scripts/hooks/post-edit-check.sh"
"scripts/hooks/stop-gate.sh"
@@ -588,6 +591,9 @@ ALL_SCRIPTS=(
"scripts/gates/gate-integration.sh"
"scripts/gates/gate-contract.sh"
"scripts/gates/gate-nfr.sh"
"scripts/gates/gate-no-placeholder.sh"
"scripts/gates/gate-frontend-api-contract.sh"
"scripts/gates/gate-admin-route-coverage.sh"
"scripts/hooks/pre-edit-guard.sh"
"scripts/hooks/post-edit-check.sh"
"scripts/hooks/stop-gate.sh"