From 40db55e85d90c3dbef2b0d97d817d96fbaede313 Mon Sep 17 00:00:00 2001 From: super Date: Tue, 27 Jan 2026 20:48:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 21 + README.md | 156 ++ docs/domain_integration_guide.md | 142 + init-database.bat | 49 + logs/nanxiislet-admin.log.2026-01-21.0.gz | Bin 0 -> 281318 bytes pom.xml | 223 ++ .../admin/NanxiisletAdminApplication.java | 30 + .../admin/common/base/BaseEntity.java | 47 + .../admin/common/base/BasePageQuery.java | 42 + .../common/exception/BusinessException.java | 46 + .../exception/GlobalExceptionHandler.java | 148 ++ .../admin/common/result/PageResult.java | 63 + .../com/nanxiislet/admin/common/result/R.java | 107 + .../admin/common/result/ResultCode.java | 68 + .../admin/config/CaptchaConfig.java | 55 + .../admin/config/DatabaseInitializer.java | 77 + .../admin/config/MybatisPlusConfig.java | 88 + .../nanxiislet/admin/config/RedisConfig.java | 53 + .../admin/config/StpInterfaceImpl.java | 87 + .../admin/config/SwaggerConfig.java | 46 + .../nanxiislet/admin/config/WebMvcConfig.java | 84 + .../admin/controller/AuthController.java | 52 + .../admin/controller/DashboardController.java | 127 + .../admin/controller/DatabaseController.java | 284 ++ .../admin/controller/FileController.java | 233 ++ .../controller/FinanceAccountController.java | 70 + .../controller/FinanceBudgetController.java | 99 + .../controller/FinanceExpenseController.java | 67 + .../controller/FinanceIncomeController.java | 70 + .../controller/FinanceInvoiceController.java | 93 + .../FinanceReimbursementController.java | 79 + .../FinanceSettlementController.java | 109 + .../admin/controller/HealthController.java | 51 + .../PlatformCertificateController.java | 138 + .../controller/PlatformDomainController.java | 275 ++ .../controller/PlatformProjectController.java | 418 +++ .../controller/PlatformServerController.java | 135 + .../admin/controller/RuntimeController.java | 297 +++ .../controller/SysApprovalController.java | 153 ++ .../admin/controller/SysDeptController.java | 73 + .../admin/controller/SysDictController.java | 126 + .../admin/controller/SysMenuController.java | 85 + .../admin/controller/SysRoleController.java | 99 + .../admin/controller/UploadController.java | 89 + .../admin/controller/UserController.java | 116 + .../admin/dto/CertificateApplyRequest.java | 46 + .../admin/dto/CertificateApplyResult.java | 80 + .../nanxiislet/admin/dto/DeployRequest.java | 30 + .../nanxiislet/admin/dto/DeployResult.java | 114 + .../admin/dto/DomainDeployRequest.java | 33 + .../admin/dto/DomainDeployResult.java | 80 + .../nanxiislet/admin/dto/DomainQueryDTO.java | 26 + .../nanxiislet/admin/dto/DomainStatsDTO.java | 30 + .../nanxiislet/admin/dto/ServerInfoDto.java | 130 + .../dto/approval/ApprovalInstanceVO.java | 24 + .../admin/dto/approval/ApprovalStats.java | 48 + .../dto/approval/ApprovalTemplateRequest.java | 46 + .../dto/approval/ApprovalTemplateVO.java | 24 + .../admin/dto/auth/CaptchaResponse.java | 27 + .../admin/dto/auth/LoginRequest.java | 32 + .../admin/dto/auth/LoginResponse.java | 33 + .../nanxiislet/admin/dto/auth/UserInfoVO.java | 56 + .../admin/dto/query/ExpenseQuery.java | 36 + .../admin/dto/query/IncomeQuery.java | 36 + .../admin/dto/query/ProjectQuery.java | 27 + .../admin/dto/system/UserQuery.java | 26 + .../admin/entity/FinanceAccount.java | 58 + .../admin/entity/FinanceBudget.java | 88 + .../admin/entity/FinanceExpense.java | 83 + .../admin/entity/FinanceIncome.java | 89 + .../admin/entity/FinanceInvoice.java | 53 + .../admin/entity/FinanceReimbursement.java | 100 + .../admin/entity/FinanceSettlement.java | 80 + .../admin/entity/PlatformCertificate.java | 89 + .../admin/entity/PlatformDomain.java | 115 + .../admin/entity/PlatformProject.java | 107 + .../admin/entity/PlatformServer.java | 67 + .../admin/entity/SysApprovalInstance.java | 107 + .../admin/entity/SysApprovalNode.java | 89 + .../admin/entity/SysApprovalRecord.java | 64 + .../admin/entity/SysApprovalTemplate.java | 69 + .../com/nanxiislet/admin/entity/SysDept.java | 106 + .../com/nanxiislet/admin/entity/SysDict.java | 32 + .../nanxiislet/admin/entity/SysDictItem.java | 41 + .../com/nanxiislet/admin/entity/SysMenu.java | 59 + .../com/nanxiislet/admin/entity/SysRole.java | 35 + .../nanxiislet/admin/entity/SysRoleMenu.java | 35 + .../com/nanxiislet/admin/entity/SysUser.java | 59 + .../admin/mapper/FinanceAccountMapper.java | 12 + .../admin/mapper/FinanceBudgetMapper.java | 12 + .../admin/mapper/FinanceExpenseMapper.java | 12 + .../admin/mapper/FinanceIncomeMapper.java | 12 + .../admin/mapper/FinanceInvoiceMapper.java | 12 + .../mapper/FinanceReimbursementMapper.java | 12 + .../admin/mapper/FinanceSettlementMapper.java | 12 + .../mapper/PlatformCertificateMapper.java | 15 + .../admin/mapper/PlatformDomainMapper.java | 18 + .../admin/mapper/PlatformProjectMapper.java | 12 + .../admin/mapper/PlatformServerMapper.java | 12 + .../mapper/SysApprovalInstanceMapper.java | 15 + .../admin/mapper/SysApprovalNodeMapper.java | 15 + .../admin/mapper/SysApprovalRecordMapper.java | 15 + .../mapper/SysApprovalTemplateMapper.java | 15 + .../admin/mapper/SysDeptMapper.java | 15 + .../admin/mapper/SysDictItemMapper.java | 15 + .../admin/mapper/SysDictMapper.java | 15 + .../admin/mapper/SysMenuMapper.java | 38 + .../admin/mapper/SysRoleMapper.java | 15 + .../admin/mapper/SysRoleMenuMapper.java | 26 + .../admin/mapper/SysUserMapper.java | 15 + .../nanxiislet/admin/service/AuthService.java | 49 + .../admin/service/FinanceAccountService.java | 14 + .../admin/service/FinanceBudgetService.java | 53 + .../admin/service/FinanceExpenseService.java | 14 + .../admin/service/FinanceIncomeService.java | 30 + .../admin/service/FinanceInvoiceService.java | 38 + .../service/FinanceReimbursementService.java | 14 + .../service/FinanceSettlementService.java | 52 + .../admin/service/OnePanelService.java | 437 +++ .../service/PlatformCertificateService.java | 93 + .../admin/service/PlatformDomainService.java | 91 + .../admin/service/PlatformProjectService.java | 13 + .../admin/service/PlatformServerService.java | 39 + .../service/SysApprovalInstanceService.java | 50 + .../service/SysApprovalTemplateService.java | 59 + .../admin/service/SysDeptService.java | 45 + .../admin/service/SysDictItemService.java | 45 + .../admin/service/SysDictService.java | 39 + .../admin/service/SysMenuService.java | 50 + .../admin/service/SysRoleService.java | 56 + .../nanxiislet/admin/service/UserService.java | 63 + .../admin/service/impl/AuthServiceImpl.java | 223 ++ .../impl/FinanceAccountServiceImpl.java | 32 + .../impl/FinanceBudgetServiceImpl.java | 108 + .../impl/FinanceExpenseServiceImpl.java | 48 + .../impl/FinanceIncomeServiceImpl.java | 53 + .../impl/FinanceInvoiceServiceImpl.java | 81 + .../impl/FinanceReimbursementServiceImpl.java | 48 + .../impl/FinanceSettlementServiceImpl.java | 131 + .../service/impl/OnePanelServiceImpl.java | 2336 +++++++++++++++++ .../impl/PlatformCertificateServiceImpl.java | 665 +++++ .../impl/PlatformDomainServiceImpl.java | 1064 ++++++++ .../impl/PlatformProjectServiceImpl.java | 37 + .../impl/PlatformServerServiceImpl.java | 419 +++ .../impl/SysApprovalInstanceServiceImpl.java | 259 ++ .../impl/SysApprovalTemplateServiceImpl.java | 228 ++ .../service/impl/SysDeptServiceImpl.java | 139 + .../service/impl/SysDictItemServiceImpl.java | 101 + .../service/impl/SysDictServiceImpl.java | 82 + .../service/impl/SysMenuServiceImpl.java | 102 + .../service/impl/SysRoleServiceImpl.java | 122 + .../admin/service/impl/UserServiceImpl.java | 145 + src/main/resources/application.yml | 174 ++ src/main/resources/db/V2__user_role_menu.sql | 132 + src/main/resources/db/V3__dict.sql | 76 + src/main/resources/db/V4__approval.sql | 163 ++ src/main/resources/db/V5__dept_menu.sql | 23 + src/main/resources/db/V6__domain_enhance.sql | 20 + src/main/resources/db/V7__certificate.sql | 47 + .../db/V8__add_runtime_to_domain.sql | 12 + src/main/resources/db/fix_approval_tables.sql | 179 ++ src/main/resources/db/fix_project_table.sql | 22 + src/main/resources/db/init.sql | 418 +++ src/main/resources/db/init_domain_data.sql | 20 + .../resources/db/init_domain_dictionaries.sql | 38 + .../resources/db/init_platform_server.sql | 83 + src/main/resources/db/init_server_data.sql | 52 + .../resources/db/init_server_dictionaries.sql | 41 + .../V20260115_2__add_menu_project_id.sql | 6 + .../V20260115_3__import_codeport_menus.sql | 71 + .../V20260115__add_project_system_type.sql | 4 + .../V20260117__add_database_menu.sql | 13 + .../migration/V20260118__add_runtime_menu.sql | 13 + .../resources/db/migration/nanxiislet.sql | 972 +++++++ .../resources/db/migration_project_deploy.sql | 83 + src/main/resources/db/remove_ssh_columns.sql | 3 + src/main/resources/schema.sql | 554 ++++ 177 files changed, 18905 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/domain_integration_guide.md create mode 100644 init-database.bat create mode 100644 logs/nanxiislet-admin.log.2026-01-21.0.gz create mode 100644 pom.xml create mode 100644 src/main/java/com/nanxiislet/admin/NanxiisletAdminApplication.java create mode 100644 src/main/java/com/nanxiislet/admin/common/base/BaseEntity.java create mode 100644 src/main/java/com/nanxiislet/admin/common/base/BasePageQuery.java create mode 100644 src/main/java/com/nanxiislet/admin/common/exception/BusinessException.java create mode 100644 src/main/java/com/nanxiislet/admin/common/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/nanxiislet/admin/common/result/PageResult.java create mode 100644 src/main/java/com/nanxiislet/admin/common/result/R.java create mode 100644 src/main/java/com/nanxiislet/admin/common/result/ResultCode.java create mode 100644 src/main/java/com/nanxiislet/admin/config/CaptchaConfig.java create mode 100644 src/main/java/com/nanxiislet/admin/config/DatabaseInitializer.java create mode 100644 src/main/java/com/nanxiislet/admin/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/nanxiislet/admin/config/RedisConfig.java create mode 100644 src/main/java/com/nanxiislet/admin/config/StpInterfaceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/config/SwaggerConfig.java create mode 100644 src/main/java/com/nanxiislet/admin/config/WebMvcConfig.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/AuthController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/DashboardController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/DatabaseController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FileController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceAccountController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceBudgetController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceExpenseController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceIncomeController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceInvoiceController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceReimbursementController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/FinanceSettlementController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/HealthController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/PlatformCertificateController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/PlatformDomainController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/PlatformProjectController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/PlatformServerController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/RuntimeController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/SysApprovalController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/SysDeptController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/SysDictController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/SysMenuController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/SysRoleController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/UploadController.java create mode 100644 src/main/java/com/nanxiislet/admin/controller/UserController.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/CertificateApplyRequest.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/CertificateApplyResult.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DeployRequest.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DeployResult.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DomainDeployRequest.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DomainDeployResult.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DomainQueryDTO.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/DomainStatsDTO.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/ServerInfoDto.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/approval/ApprovalInstanceVO.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/approval/ApprovalStats.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateRequest.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateVO.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/auth/CaptchaResponse.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/auth/LoginRequest.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/auth/LoginResponse.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/auth/UserInfoVO.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/query/ExpenseQuery.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/query/IncomeQuery.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/query/ProjectQuery.java create mode 100644 src/main/java/com/nanxiislet/admin/dto/system/UserQuery.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceAccount.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceBudget.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceExpense.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceIncome.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceInvoice.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceReimbursement.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/FinanceSettlement.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/PlatformCertificate.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/PlatformDomain.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/PlatformProject.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/PlatformServer.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysApprovalInstance.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysApprovalNode.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysApprovalRecord.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysApprovalTemplate.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysDept.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysDict.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysDictItem.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysMenu.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysRole.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysRoleMenu.java create mode 100644 src/main/java/com/nanxiislet/admin/entity/SysUser.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceAccountMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceBudgetMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceExpenseMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceIncomeMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceInvoiceMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceReimbursementMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/FinanceSettlementMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/PlatformCertificateMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/PlatformDomainMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/PlatformProjectMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/PlatformServerMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysApprovalInstanceMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysApprovalNodeMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysApprovalRecordMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysApprovalTemplateMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysDeptMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysDictItemMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysDictMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysMenuMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysRoleMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysRoleMenuMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/mapper/SysUserMapper.java create mode 100644 src/main/java/com/nanxiislet/admin/service/AuthService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceAccountService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceBudgetService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceExpenseService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceIncomeService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceInvoiceService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceReimbursementService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/FinanceSettlementService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/OnePanelService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/PlatformCertificateService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/PlatformDomainService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/PlatformProjectService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/PlatformServerService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysApprovalInstanceService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysApprovalTemplateService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysDeptService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysDictItemService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysDictService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysMenuService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/SysRoleService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/UserService.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/AuthServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceAccountServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceBudgetServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceExpenseServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceIncomeServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceInvoiceServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceReimbursementServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/FinanceSettlementServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/OnePanelServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/PlatformCertificateServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/PlatformDomainServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/PlatformProjectServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/PlatformServerServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysApprovalInstanceServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysApprovalTemplateServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysDeptServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysDictItemServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysDictServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysMenuServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/SysRoleServiceImpl.java create mode 100644 src/main/java/com/nanxiislet/admin/service/impl/UserServiceImpl.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/V2__user_role_menu.sql create mode 100644 src/main/resources/db/V3__dict.sql create mode 100644 src/main/resources/db/V4__approval.sql create mode 100644 src/main/resources/db/V5__dept_menu.sql create mode 100644 src/main/resources/db/V6__domain_enhance.sql create mode 100644 src/main/resources/db/V7__certificate.sql create mode 100644 src/main/resources/db/V8__add_runtime_to_domain.sql create mode 100644 src/main/resources/db/fix_approval_tables.sql create mode 100644 src/main/resources/db/fix_project_table.sql create mode 100644 src/main/resources/db/init.sql create mode 100644 src/main/resources/db/init_domain_data.sql create mode 100644 src/main/resources/db/init_domain_dictionaries.sql create mode 100644 src/main/resources/db/init_platform_server.sql create mode 100644 src/main/resources/db/init_server_data.sql create mode 100644 src/main/resources/db/init_server_dictionaries.sql create mode 100644 src/main/resources/db/migration/V20260115_2__add_menu_project_id.sql create mode 100644 src/main/resources/db/migration/V20260115_3__import_codeport_menus.sql create mode 100644 src/main/resources/db/migration/V20260115__add_project_system_type.sql create mode 100644 src/main/resources/db/migration/V20260117__add_database_menu.sql create mode 100644 src/main/resources/db/migration/V20260118__add_runtime_menu.sql create mode 100644 src/main/resources/db/migration/nanxiislet.sql create mode 100644 src/main/resources/db/migration_project_deploy.sql create mode 100644 src/main/resources/db/remove_ssh_columns.sql create mode 100644 src/main/resources/schema.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab4c169 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +*.class +*.log +*.ctxt +.mtj.tmp/ +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +.classpath +.project +.settings +.target +.idea +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6f0589 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Nanxiislet Admin Backend + +南溪小岛管理后台 Spring Boot 后端服务 + +## 技术栈 + +- **框架**: Spring Boot 3.3.4 +- **Java 版本**: JDK 21 +- **数据库**: MySQL 8.0+ +- **缓存**: Redis +- **ORM**: MyBatis-Plus 3.5.7 +- **认证**: Sa-Token 1.39.0 +- **API 文档**: Knife4j 4.5.0 (OpenAPI 3) +- **工具库**: Hutool、Lombok、EasyExcel + +## 项目结构 + +``` +src/main/java/com/nanxiislet/admin/ +├── common/ # 公共模块 +│ ├── base/ # 基础类 +│ ├── exception/ # 异常处理 +│ └── result/ # 响应结果 +├── config/ # 配置类 +├── controller/ # 控制器 +├── dto/ # 数据传输对象 +│ ├── auth/ # 认证相关 +│ └── query/ # 查询条件 +├── entity/ # 实体类 +├── mapper/ # MyBatis Mapper +└── service/ # 服务层 + └── impl/ # 服务实现 +``` + +## 功能模块 + +### 认证管理 + +- 用户登录/登出 +- 验证码 +- 获取当前用户信息 + +### 用户管理 + +- 用户 CRUD +- 密码重置/修改 +- 用户状态管理 + +### 平台管理 + +- 项目管理 +- 服务器管理 +- 域名管理 + +### 财务管理 + +- 收入管理 +- 支出管理 +- 预算管理 +- 报销管理 +- 发票管理 +- 结算管理 + +### 仪表盘 + +- 统计数据 +- 快速概览 + +## 快速开始 + +### 1. 环境要求 + +- JDK 21+ +- Maven 3.8+ +- MySQL 8.0+ +- Redis 6.0+ + +### 2. 数据库初始化 + +执行 `src/main/resources/db/init.sql` 脚本创建数据库和表。 + +```bash +mysql -h 192.168.9.100 -u root -p < src/main/resources/db/init.sql +``` + +### 3. 配置修改 + +编辑 `src/main/resources/application.yml`,修改数据库和 Redis 连接信息。 + +### 4. 启动项目 + +```bash +mvn spring-boot:run +``` + +或打包后运行: + +```bash +mvn package -DskipTests +java -jar target/nanxiislet-admin-1.0.0-SNAPSHOT.jar +``` + +### 5. 访问 API 文档 + +启动后访问: http://localhost:8080/api/doc.html + +## 默认账户 + +- 用户名: `admin` +- 密码: `admin123` + +## API 说明 + +### 认证接口 + +| 方法 | 路径 | 说明 | +| ---- | --------------- | ---------------- | +| GET | /auth/captcha | 获取验证码 | +| POST | /auth/login | 用户登录 | +| POST | /auth/logout | 用户登出 | +| GET | /auth/user-info | 获取当前用户信息 | + +### 用户管理 + +| 方法 | 路径 | 说明 | +| ------ | -------------------------------- | -------- | +| GET | /system/user/list | 用户列表 | +| GET | /system/user/{id} | 用户详情 | +| POST | /system/user | 新增用户 | +| PUT | /system/user/{id} | 更新用户 | +| DELETE | /system/user/{id} | 删除用户 | +| POST | /system/user/{id}/reset-password | 重置密码 | +| POST | /system/user/change-password | 修改密码 | + +### 请求头 + +需要登录的接口请在请求头中携带 Token: + +``` +Authorization: Bearer {token} +``` + +## 响应格式 + +```json +{ + "code": 200, + "message": "操作成功", + "data": {}, + "timestamp": 1704540000000 +} +``` + +## License + +MIT License diff --git a/docs/domain_integration_guide.md b/docs/domain_integration_guide.md new file mode 100644 index 0000000..f02c338 --- /dev/null +++ b/docs/domain_integration_guide.md @@ -0,0 +1,142 @@ +# 域名管理集成指南 + +## 1Panel 接口文档 + +1Panel API 文档地址:http://47.109.57.58:42588/1panel/swagger/index.html + +## 架构设计 + +### 数据流 + +``` +前端(域名管理页面) + ↓ +后端(PlatformDomainController) + ↓ +后端(PlatformDomainService) + ↓ 存储/读取 +数据库(platform_domain表) + ↓ 部署时调用 +1Panel API(创建网站/申请证书/配置HTTPS) +``` + +### 核心流程 + +1. **域名创建**:用户在前端新建域名 → 后端存储到数据库(状态为 `pending`,部署状态为 `not_deployed`) + +2. **项目关联**:用户在项目管理中绑定域名 → 自动生成项目地址和部署路径 + +3. **项目部署**(点击"部署"按钮时触发): + - 检查/创建 1Panel 网站 + - 如果启用 HTTPS,检查/申请 SSL 证书 + - 配置 HTTPS(强制跳转) + - 更新域名和项目状态 + +## 后端接口 + +### 域名管理 API + +| 方法 | 路径 | 描述 | +| ------ | ------------------------------- | ---------------------- | +| GET | /platform/domain/list | 分页获取域名列表 | +| GET | /platform/domain/all | 获取所有域名 | +| GET | /platform/domain/{id} | 获取域名详情 | +| POST | /platform/domain | 新增域名 | +| PUT | /platform/domain/{id} | 更新域名 | +| DELETE | /platform/domain/{id} | 删除域名 | +| GET | /platform/domain/stats | 域名统计信息 | +| POST | /platform/domain/deploy | 部署域名到 1Panel | +| POST | /platform/domain/{id}/check-dns | 检查 DNS 解析状态 | +| POST | /platform/domain/{id}/sync | 从 1Panel 同步域名信息 | + +### 部署请求参数 + +```json +{ + "domainId": 1, + "enableHttps": true, + "acmeAccountId": 1, + "dnsAccountId": 1, + "createIfNotExist": true +} +``` + +### 部署结果 + +```json +{ + "success": true, + "message": "部署流程完成", + "websiteId": 123, + "sslCertificateId": 456, + "steps": [ + { + "step": "检查网站", + "status": "success", + "message": "网站已存在,ID: 123" + }, + { + "step": "检查SSL证书", + "status": "success", + "message": "证书已存在,ID: 456" + }, + { "step": "配置HTTPS", "status": "success", "message": "HTTPS配置成功" } + ] +} +``` + +## 数据库表结构 + +### platform_domain 表 + +新增字段(V6\_\_domain_enhance.sql): + +- `panel_website_id` - 1Panel 网站 ID +- `panel_ssl_id` - 1Panel 证书 ID +- `site_path` - 网站目录路径 +- `alias` - 网站别名 +- `deploy_status` - 部署状态(not_deployed/deploying/deployed/failed) +- `last_deploy_time` - 最后部署时间 +- `last_deploy_message` - 最后部署消息 + +## 前端集成 + +### API 文件 + +`src/api/domain.ts` - 域名管理 API 封装 + +### 使用示例 + +```typescript +import { + getDomainList, + createDomain, + deployDomain, + checkDomainDns, +} from "@/api/domain"; + +// 获取域名列表 +const { records, total } = await getDomainList({ page: 1, pageSize: 10 }); + +// 创建域名 +await createDomain({ + domain: "example.com", + serverId: 1, + port: 80, +}); + +// 部署域名 +const result = await deployDomain({ + domainId: 1, + enableHttps: true, + acmeAccountId: 1, + dnsAccountId: 1, +}); +``` + +## 注意事项 + +1. 部署操作是异步的,可能需要等待证书申请完成(最长 120 秒) +2. 首次部署会自动创建网站目录,路径为 `/opt/1panel/www/sites/{域名}/index` +3. SSL 证书申请需要配置 Acme 账户和 DNS 账户(在 1Panel 中配置) +4. 部署完成后,项目的"部署"按钮会自动同步状态 diff --git a/init-database.bat b/init-database.bat new file mode 100644 index 0000000..fb7ff94 --- /dev/null +++ b/init-database.bat @@ -0,0 +1,49 @@ +@echo off +REM 数据库初始化脚本 +REM 请先配置好MySQL可执行文件路径 + +set MYSQL_PATH=mysql +set HOST=192.168.9.100 +set PORT=3306 +set USER=root +set PASSWORD=mysql_23fw7s +set DATABASE=nanxiislet + +echo ================================ +echo 南溪小岛管理后台 - 数据库初始化 +echo ================================ +echo. +echo 数据库地址: %HOST%:%PORT% +echo 数据库名称: %DATABASE% +echo. + +REM 先创建数据库 +echo 正在创建数据库... +%MYSQL_PATH% -h%HOST% -P%PORT% -u%USER% -p%PASSWORD% -e "CREATE DATABASE IF NOT EXISTS %DATABASE% DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + +if %ERRORLEVEL% NEQ 0 ( + echo 创建数据库失败!请检查MySQL连接配置。 + pause + exit /b 1 +) + +REM 导入初始化脚本 +echo 正在导入数据表... +%MYSQL_PATH% -h%HOST% -P%PORT% -u%USER% -p%PASSWORD% %DATABASE% < src\main\resources\db\init.sql + +if %ERRORLEVEL% NEQ 0 ( + echo 导入数据失败! + pause + exit /b 1 +) + +echo. +echo ================================ +echo 数据库初始化成功! +echo ================================ +echo. +echo 默认管理员账号: +echo 用户名: admin +echo 密码: admin123 +echo. +pause diff --git a/logs/nanxiislet-admin.log.2026-01-21.0.gz b/logs/nanxiislet-admin.log.2026-01-21.0.gz new file mode 100644 index 0000000000000000000000000000000000000000..471cbee67c863314dcbd7780fa82c884fbf9ddf1 GIT binary patch literal 281318 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

DT2KbbP0dMq2>!V9I$d_i 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 + + 4.0.0 + + com.nanxiislet + nanxiislet-admin + 1.0.0-SNAPSHOT + jar + + NanxiIslet Admin Backend + 南溪小岛管理后台 - Spring Boot后端服务 + + + 21 + UTF-8 + UTF-8 + 3.3.4 + 3.5.7 + 0.12.6 + 5.8.31 + 4.5.0 + 1.39.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + com.mysql + mysql-connector-j + runtime + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.alibaba + druid-spring-boot-3-starter + 1.2.23 + + + + + cn.dev33 + sa-token-spring-boot3-starter + ${sa-token.version} + + + cn.dev33 + sa-token-redis-jackson + ${sa-token.version} + + + + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + runtime + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.security + spring-security-crypto + + + + + org.apache.commons + commons-lang3 + + + org.apache.commons + commons-pool2 + + + + + commons-io + commons-io + 2.17.0 + + + + + com.alibaba + easyexcel + 4.0.3 + + + + + pro.fessional + kaptcha + 2.3.3 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${java.version} + UTF-8 + + -parameters + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.projectlombok + lombok + + + + + + + repackage + + + + + + + + + + aliyun + Aliyun Maven Repository + https://maven.aliyun.com/repository/public + + + + diff --git a/src/main/java/com/nanxiislet/admin/NanxiisletAdminApplication.java b/src/main/java/com/nanxiislet/admin/NanxiisletAdminApplication.java new file mode 100644 index 0000000..c3a35c2 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/NanxiisletAdminApplication.java @@ -0,0 +1,30 @@ +package com.nanxiislet.admin; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 南溪小岛管理后台 - 启动类 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@SpringBootApplication +@MapperScan("com.nanxiislet.admin.mapper") +@EnableTransactionManagement +@EnableAsync +@EnableScheduling +public class NanxiisletAdminApplication { + + public static void main(String[] args) { + SpringApplication.run(NanxiisletAdminApplication.class, args); + System.out.println("===================================================="); + System.out.println(" 南溪小岛管理后台服务启动成功!"); + System.out.println(" 接口文档地址: http://localhost:8080/api/doc.html"); + System.out.println("===================================================="); + } +} diff --git a/src/main/java/com/nanxiislet/admin/common/base/BaseEntity.java b/src/main/java/com/nanxiislet/admin/common/base/BaseEntity.java new file mode 100644 index 0000000..7e7c370 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/base/BaseEntity.java @@ -0,0 +1,47 @@ +package com.nanxiislet.admin.common.base; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 实体基类 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +public class BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "创建时间") + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + + @Schema(description = "创建人ID") + @TableField(fill = FieldFill.INSERT) + private Long createdBy; + + @Schema(description = "更新人ID") + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updatedBy; + + @Schema(description = "删除标记 0-未删除 1-已删除") + @TableLogic + @TableField(fill = FieldFill.INSERT) + private Integer deleted; +} diff --git a/src/main/java/com/nanxiislet/admin/common/base/BasePageQuery.java b/src/main/java/com/nanxiislet/admin/common/base/BasePageQuery.java new file mode 100644 index 0000000..b33232b --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/base/BasePageQuery.java @@ -0,0 +1,42 @@ +package com.nanxiislet.admin.common.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 分页查询基类 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +public class BasePageQuery implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "当前页码", example = "1") + private Integer page = 1; + + @Schema(description = "每页条数", example = "10") + private Integer pageSize = 10; + + @Schema(description = "搜索关键词") + private String keyword; + + @Schema(description = "排序字段") + private String orderBy; + + @Schema(description = "排序方式 asc/desc") + private String order = "desc"; + + /** + * 获取偏移量 + */ + public int getOffset() { + return (page - 1) * pageSize; + } +} diff --git a/src/main/java/com/nanxiislet/admin/common/exception/BusinessException.java b/src/main/java/com/nanxiislet/admin/common/exception/BusinessException.java new file mode 100644 index 0000000..c381a85 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/exception/BusinessException.java @@ -0,0 +1,46 @@ +package com.nanxiislet.admin.common.exception; + +import com.nanxiislet.admin.common.result.ResultCode; +import lombok.Getter; + +import java.io.Serial; + +/** + * 业务异常 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Getter +public class BusinessException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final int code; + private final String message; + + public BusinessException(String message) { + super(message); + this.code = ResultCode.FAILED.getCode(); + this.message = message; + } + + public BusinessException(int code, String message) { + super(message); + this.code = code; + this.message = message; + } + + public BusinessException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.code = resultCode.getCode(); + this.message = resultCode.getMessage(); + } + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.code = resultCode.getCode(); + this.message = message; + } +} diff --git a/src/main/java/com/nanxiislet/admin/common/exception/GlobalExceptionHandler.java b/src/main/java/com/nanxiislet/admin/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c074b18 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,148 @@ +package com.nanxiislet.admin.common.exception; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.dev33.satoken.exception.NotRoleException; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 业务异常 + */ + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.OK) + public R handleBusinessException(BusinessException e, HttpServletRequest request) { + log.warn("业务异常: {} - {}", request.getRequestURI(), e.getMessage()); + return R.fail(e.getCode(), e.getMessage()); + } + + /** + * 未登录异常 + */ + @ExceptionHandler(NotLoginException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public R handleNotLoginException(NotLoginException e, HttpServletRequest request) { + log.warn("未登录访问: {} - {}", request.getRequestURI(), e.getMessage()); + return R.fail(ResultCode.UNAUTHORIZED); + } + + /** + * 无权限异常 + */ + @ExceptionHandler(NotPermissionException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public R handleNotPermissionException(NotPermissionException e, HttpServletRequest request) { + log.warn("无权限访问: {} - {}", request.getRequestURI(), e.getMessage()); + return R.fail(ResultCode.FORBIDDEN); + } + + /** + * 无角色异常 + */ + @ExceptionHandler(NotRoleException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public R handleNotRoleException(NotRoleException e, HttpServletRequest request) { + log.warn("无角色访问: {} - {}", request.getRequestURI(), e.getMessage()); + return R.fail(ResultCode.FORBIDDEN); + } + + /** + * 参数校验异常 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("参数校验失败: {}", message); + return R.fail(ResultCode.VALIDATE_FAILED, message); + } + + /** + * 绑定异常 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleBindException(BindException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("参数绑定失败: {}", message); + return R.fail(ResultCode.VALIDATE_FAILED, message); + } + + /** + * 缺少请求参数异常 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleMissingParameterException(MissingServletRequestParameterException e) { + log.warn("缺少请求参数: {}", e.getParameterName()); + return R.fail(ResultCode.BAD_REQUEST, "缺少请求参数: " + e.getParameterName()); + } + + /** + * 请求方法不支持异常 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public R handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.warn("不支持的请求方法: {}", e.getMethod()); + return R.fail(ResultCode.METHOD_NOT_ALLOWED); + } + + /** + * 404异常 + */ + @ExceptionHandler(NoHandlerFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public R handleNoHandlerFoundException(NoHandlerFoundException e) { + log.warn("资源不存在: {}", e.getRequestURL()); + return R.fail(ResultCode.NOT_FOUND); + } + + /** + * 数据库唯一键冲突异常 + */ + @ExceptionHandler(org.springframework.dao.DuplicateKeyException.class) + @ResponseStatus(HttpStatus.OK) + public R handleDuplicateKeyException(org.springframework.dao.DuplicateKeyException e) { + log.warn("数据重复: {}", e.getMessage()); + return R.fail(ResultCode.VALIDATE_FAILED, "数据已存在(编码或名称重复),即使是已删除的历史数据也不能重复,请更换后重试"); + } + + /** + * 其他异常 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public R handleException(Exception e, HttpServletRequest request) { + log.error("系统异常: {} - {}", request.getRequestURI(), e.getMessage(), e); + return R.fail(ResultCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"); + } +} diff --git a/src/main/java/com/nanxiislet/admin/common/result/PageResult.java b/src/main/java/com/nanxiislet/admin/common/result/PageResult.java new file mode 100644 index 0000000..d4fae48 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/result/PageResult.java @@ -0,0 +1,63 @@ +package com.nanxiislet.admin.common.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 分页结果 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Schema(description = "分页结果") +public class PageResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "数据列表") + private List records; + + @Schema(description = "总条数") + private long total; + + @Schema(description = "当前页码") + private long page; + + @Schema(description = "每页条数") + private long pageSize; + + @Schema(description = "总页数") + private long totalPages; + + public PageResult() { + } + + public PageResult(List records, long total, long page, long pageSize) { + this.records = records; + this.total = total; + this.page = page; + this.pageSize = pageSize; + this.totalPages = (total + pageSize - 1) / pageSize; + } + + /** + * 创建分页结果 + */ + public static PageResult of(List records, long total, long page, long pageSize) { + return new PageResult<>(records, total, page, pageSize); + } + + /** + * 空分页结果 + */ + public static PageResult empty() { + return new PageResult<>(List.of(), 0, 1, 10); + } +} + diff --git a/src/main/java/com/nanxiislet/admin/common/result/R.java b/src/main/java/com/nanxiislet/admin/common/result/R.java new file mode 100644 index 0000000..b4b8052 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/result/R.java @@ -0,0 +1,107 @@ +package com.nanxiislet.admin.common.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 统一响应结果 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Schema(description = "统一响应结果") +public class R implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "响应码", example = "200") + private int code; + + @Schema(description = "响应消息", example = "操作成功") + private String message; + + @Schema(description = "响应数据") + private T data; + + @Schema(description = "时间戳") + private long timestamp; + + public R() { + this.timestamp = System.currentTimeMillis(); + } + + public R(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + /** + * 成功 + */ + public static R ok() { + return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + /** + * 成功 + */ + public static R ok(T data) { + return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } + + /** + * 成功 + */ + public static R ok(String message, T data) { + return new R<>(ResultCode.SUCCESS.getCode(), message, data); + } + + /** + * 失败 + */ + public static R fail() { + return new R<>(ResultCode.FAILED.getCode(), ResultCode.FAILED.getMessage(), null); + } + + /** + * 失败 + */ + public static R fail(String message) { + return new R<>(ResultCode.FAILED.getCode(), message, null); + } + + /** + * 失败 + */ + public static R fail(int code, String message) { + return new R<>(code, message, null); + } + + /** + * 失败 + */ + public static R fail(ResultCode resultCode) { + return new R<>(resultCode.getCode(), resultCode.getMessage(), null); + } + + /** + * 失败 + */ + public static R fail(ResultCode resultCode, String message) { + return new R<>(resultCode.getCode(), message, null); + } + + /** + * 判断是否成功 + */ + public boolean isSuccess() { + return this.code == ResultCode.SUCCESS.getCode(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/common/result/ResultCode.java b/src/main/java/com/nanxiislet/admin/common/result/ResultCode.java new file mode 100644 index 0000000..f8d276b --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/common/result/ResultCode.java @@ -0,0 +1,68 @@ +package com.nanxiislet.admin.common.result; + +import lombok.Getter; + +/** + * 响应码枚举 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Getter +public enum ResultCode { + + // 成功 + SUCCESS(200, "操作成功"), + + // 客户端错误 4xx + BAD_REQUEST(400, "请求参数错误"), + UNAUTHORIZED(401, "未登录或登录已过期"), + FORBIDDEN(403, "没有操作权限"), + NOT_FOUND(404, "资源不存在"), + METHOD_NOT_ALLOWED(405, "请求方法不支持"), + CONFLICT(409, "资源冲突"), + VALIDATE_FAILED(422, "参数校验失败"), + TOO_MANY_REQUESTS(429, "请求过于频繁"), + + // 服务端错误 5xx + FAILED(500, "操作失败"), + INTERNAL_ERROR(500, "服务器内部错误"), + SERVICE_UNAVAILABLE(503, "服务暂不可用"), + + // 业务错误 1xxx + USER_NOT_EXIST(1001, "用户不存在"), + USER_PASSWORD_ERROR(1002, "用户名或密码错误"), + USER_DISABLED(1003, "用户已被禁用"), + USER_EXISTS(1004, "用户已存在"), + CAPTCHA_ERROR(1005, "验证码错误"), + CAPTCHA_EXPIRED(1006, "验证码已过期"), + TOKEN_INVALID(1007, "Token无效"), + TOKEN_EXPIRED(1008, "Token已过期"), + + // 数据相关 2xxx + DATA_NOT_EXIST(2001, "数据不存在"), + DATA_ALREADY_EXIST(2002, "数据已存在"), + DATA_SAVE_ERROR(2003, "数据保存失败"), + DATA_UPDATE_ERROR(2004, "数据更新失败"), + DATA_DELETE_ERROR(2005, "数据删除失败"), + DATA_IMPORT_ERROR(2006, "数据导入失败"), + + // 文件相关 3xxx + FILE_NOT_FOUND(3001, "文件不存在"), + FILE_UPLOAD_ERROR(3002, "文件上传失败"), + FILE_TYPE_NOT_ALLOWED(3003, "不支持的文件类型"), + FILE_SIZE_EXCEEDED(3004, "文件大小超限"), + + // 审批相关 4xxx + APPROVAL_NOT_FOUND(4001, "审批流程不存在"), + APPROVAL_ALREADY_PROCESSED(4002, "审批已处理"), + APPROVAL_NO_PERMISSION(4003, "无审批权限"); + + private final int code; + private final String message; + + ResultCode(int code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/CaptchaConfig.java b/src/main/java/com/nanxiislet/admin/config/CaptchaConfig.java new file mode 100644 index 0000000..63fcf56 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/CaptchaConfig.java @@ -0,0 +1,55 @@ +package com.nanxiislet.admin.config; + +import com.google.code.kaptcha.Producer; +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.google.code.kaptcha.util.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Properties; + +/** + * 验证码配置 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Configuration +public class CaptchaConfig { + + @Bean + public Producer captchaProducer() { + DefaultKaptcha kaptcha = new DefaultKaptcha(); + Properties properties = new Properties(); + // 验证码宽度 + properties.setProperty("kaptcha.image.width", "150"); + // 验证码高度 + properties.setProperty("kaptcha.image.height", "50"); + // 验证码字符长度 + properties.setProperty("kaptcha.textproducer.char.length", "4"); + // 验证码字符集 + properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + // 验证码字体大小 + properties.setProperty("kaptcha.textproducer.font.size", "38"); + // 验证码字体 + properties.setProperty("kaptcha.textproducer.font.names", "Arial,Courier"); + // 验证码字体颜色 + properties.setProperty("kaptcha.textproducer.font.color", "black"); + // 验证码背景颜色渐变开始 + properties.setProperty("kaptcha.background.clear.from", "lightGray"); + // 验证码背景颜色渐变结束 + properties.setProperty("kaptcha.background.clear.to", "white"); + // 验证码干扰 + properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise"); + // 验证码干扰颜色 + properties.setProperty("kaptcha.noise.color", "gray"); + // 边框 + properties.setProperty("kaptcha.border", "yes"); + properties.setProperty("kaptcha.border.color", "105,179,90"); + properties.setProperty("kaptcha.border.thickness", "1"); + + Config config = new Config(properties); + kaptcha.setConfig(config); + return kaptcha; + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/DatabaseInitializer.java b/src/main/java/com/nanxiislet/admin/config/DatabaseInitializer.java new file mode 100644 index 0000000..db0e2a6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/DatabaseInitializer.java @@ -0,0 +1,77 @@ +package com.nanxiislet.admin.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import jakarta.annotation.Resource; +import java.nio.charset.StandardCharsets; + +/** + * 数据库初始化配置 + * 在应用启动时检查并初始化数据库 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Slf4j +@Component +public class DatabaseInitializer implements CommandLineRunner { + + @Resource + private JdbcTemplate jdbcTemplate; + + @Value("${nanxiislet.db.init:false}") + private boolean initEnabled; + + @Override + public void run(String... args) throws Exception { + if (!initEnabled) { + log.info("数据库初始化已禁用,跳过初始化"); + return; + } + + try { + // 检查表是否存在 + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_user'", + Integer.class + ); + + if (count != null && count > 0) { + log.info("数据库表已存在,跳过初始化"); + return; + } + + log.info("开始初始化数据库..."); + + // 读取SQL文件 + ClassPathResource resource = new ClassPathResource("db/init.sql"); + String sql = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + + // 分割并执行SQL语句 + String[] statements = sql.split(";"); + int executed = 0; + for (String statement : statements) { + String trimmed = statement.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("--")) { + try { + jdbcTemplate.execute(trimmed); + executed++; + } catch (Exception e) { + log.warn("执行SQL失败: {}", e.getMessage()); + } + } + } + + log.info("数据库初始化完成,执行了 {} 条SQL语句", executed); + + } catch (Exception e) { + log.error("数据库初始化失败: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/MybatisPlusConfig.java b/src/main/java/com/nanxiislet/admin/config/MybatisPlusConfig.java new file mode 100644 index 0000000..dccd67f --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/MybatisPlusConfig.java @@ -0,0 +1,88 @@ +package com.nanxiislet.admin.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 配置 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Configuration +public class MybatisPlusConfig { + + /** + * MyBatis-Plus 插件配置 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + // 分页插件 + PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); + paginationInterceptor.setMaxLimit(500L); + paginationInterceptor.setOverflow(true); + interceptor.addInnerInterceptor(paginationInterceptor); + // 乐观锁插件 + interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); + // 防止全表更新删除插件 + interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); + return interceptor; + } + + /** + * 自动填充处理器 + */ + @Component + public static class AutoFillMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "deleted", Integer.class, 0); + + // 获取当前登录用户ID + Long userId = getCurrentUserId(); + if (userId != null) { + this.strictInsertFill(metaObject, "createdBy", Long.class, userId); + this.strictInsertFill(metaObject, "updatedBy", Long.class, userId); + } + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); + + Long userId = getCurrentUserId(); + if (userId != null) { + this.strictUpdateFill(metaObject, "updatedBy", Long.class, userId); + } + } + + /** + * 获取当前登录用户ID + */ + private Long getCurrentUserId() { + try { + Object loginId = cn.dev33.satoken.stp.StpUtil.getLoginIdDefaultNull(); + if (loginId != null) { + return Long.parseLong(loginId.toString()); + } + } catch (Exception ignored) { + } + return null; + } + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/RedisConfig.java b/src/main/java/com/nanxiislet/admin/config/RedisConfig.java new file mode 100644 index 0000000..888bcff --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/RedisConfig.java @@ -0,0 +1,53 @@ +package com.nanxiislet.admin.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // JSON序列化器 + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = + new Jackson2JsonRedisSerializer<>(objectMapper, Object.class); + + StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); + + // key使用String序列化 + template.setKeySerializer(stringRedisSerializer); + template.setHashKeySerializer(stringRedisSerializer); + + // value使用JSON序列化 + template.setValueSerializer(jackson2JsonRedisSerializer); + template.setHashValueSerializer(jackson2JsonRedisSerializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/StpInterfaceImpl.java b/src/main/java/com/nanxiislet/admin/config/StpInterfaceImpl.java new file mode 100644 index 0000000..3bff68d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/StpInterfaceImpl.java @@ -0,0 +1,87 @@ +package com.nanxiislet.admin.config; + +import cn.dev33.satoken.stp.StpInterface; +import cn.dev33.satoken.stp.StpUtil; +import com.nanxiislet.admin.entity.SysMenu; +import com.nanxiislet.admin.entity.SysUser; +import com.nanxiislet.admin.service.AuthService; +import com.nanxiislet.admin.service.SysMenuService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Sa-Token 权限认证接口实现 + * 用于获取当前用户的角色和权限列表 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Component +public class StpInterfaceImpl implements StpInterface { + + @Resource + private AuthService authService; + + @Resource + private SysMenuService menuService; + + /** + * 获取用户权限列表 + */ + @Override + public List getPermissionList(Object loginId, String loginType) { + // 获取用户信息 + Long userId = Long.parseLong(loginId.toString()); + SysUser user = authService.getById(userId); + if (user == null) { + return new ArrayList<>(); + } + + String roleCode = user.getRole(); + + // 超级管理员拥有所有权限 + if ("super_admin".equals(roleCode)) { + return List.of("*"); + } + + // 根据角色获取菜单权限 + List menus = menuService.getMenusByRoleCode(roleCode); + if (menus == null || menus.isEmpty()) { + return new ArrayList<>(); + } + + return menus.stream() + .map(SysMenu::getCode) + .distinct() + .toList(); + } + + /** + * 获取用户角色列表 + */ + @Override + public List getRoleList(Object loginId, String loginType) { + Long userId = Long.parseLong(loginId.toString()); + SysUser user = authService.getById(userId); + if (user == null) { + return new ArrayList<>(); + } + + List roles = new ArrayList<>(); + String roleCode = user.getRole(); + + if (roleCode != null && !roleCode.isEmpty()) { + roles.add(roleCode); + + // 超级管理员同时拥有 admin 角色 + if ("super_admin".equals(roleCode)) { + roles.add("admin"); + } + } + + return roles; + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/SwaggerConfig.java b/src/main/java/com/nanxiislet/admin/config/SwaggerConfig.java new file mode 100644 index 0000000..729f4d6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/SwaggerConfig.java @@ -0,0 +1,46 @@ +package com.nanxiislet.admin.config; + +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger/OpenAPI 配置 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("南溪小岛管理后台 API") + .description("南溪小岛管理后台 RESTful API 文档") + .version("1.0.0") + .contact(new Contact() + .name("NanxiIslet") + .email("admin@nanxiislet.com")) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT"))) + .externalDocs(new ExternalDocumentation() + .description("项目文档") + .url("https://doc.nanxiislet.com")) + .addSecurityItem(new SecurityRequirement().addList("Authorization")) + .schemaRequirement("Authorization", new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER)); + } +} diff --git a/src/main/java/com/nanxiislet/admin/config/WebMvcConfig.java b/src/main/java/com/nanxiislet/admin/config/WebMvcConfig.java new file mode 100644 index 0000000..4cee022 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/config/WebMvcConfig.java @@ -0,0 +1,84 @@ +package com.nanxiislet.admin.config; + +import cn.dev33.satoken.context.SaHolder; +import cn.dev33.satoken.interceptor.SaInterceptor; +import cn.dev33.satoken.stp.StpUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + /** + * 跨域配置 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + /** + * 拦截器配置 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + // Sa-Token 鉴权拦截器 + registry.addInterceptor(new SaInterceptor(handle -> { + // OPTIONS 预检请求不检查登录 + if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) { + return; + } + StpUtil.checkLogin(); + })) + .addPathPatterns("/**") + .excludePathPatterns( + // 登录认证相关 + "/auth/login", + "/auth/captcha", + "/auth/logout", + // Swagger文档 + "/doc.html", + "/swagger-ui.html", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/webjars/**", + // 静态资源 + "/static/**", + "/favicon.ico", + // 健康检查 + "/actuator/**", + "/health", + "/" // 根路径 + ); + } + + /** + * 静态资源配置 + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 上传文件访问 + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:./uploads/"); + // Swagger UI + registry.addResourceHandler("doc.html") + .addResourceLocations("classpath:/META-INF/resources/"); + registry.addResourceHandler("/webjars/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/"); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/AuthController.java b/src/main/java/com/nanxiislet/admin/controller/AuthController.java new file mode 100644 index 0000000..f9cc854 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.dto.auth.*; +import com.nanxiislet.admin.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * 认证控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Slf4j +@RestController +@RequestMapping("/auth") +@Tag(name = "认证管理", description = "登录、登出、验证码等") +public class AuthController { + + @Resource + private AuthService authService; + + @GetMapping("/captcha") + @Operation(summary = "获取验证码") + public R getCaptcha() { + return R.ok(authService.generateCaptcha()); + } + + @PostMapping("/login") + @Operation(summary = "用户登录") + public R login(@Valid @RequestBody LoginRequest request) { + return R.ok(authService.login(request)); + } + + @PostMapping("/logout") + @Operation(summary = "用户登出") + public R logout() { + authService.logout(); + return R.ok(); + } + + @GetMapping("/user-info") + @Operation(summary = "获取当前用户信息") + public R getCurrentUser() { + return R.ok(authService.getCurrentUser()); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/DashboardController.java b/src/main/java/com/nanxiislet/admin/controller/DashboardController.java new file mode 100644 index 0000000..e17e3b4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/DashboardController.java @@ -0,0 +1,127 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.service.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.Data; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 仪表盘控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/dashboard") +@Tag(name = "仪表盘", description = "首页统计数据") +public class DashboardController { + + @Resource + private FinanceIncomeService incomeService; + + @Resource + private FinanceExpenseService expenseService; + + @Resource + private FinanceReimbursementService reimbursementService; + + @Resource + private PlatformProjectService projectService; + + @Resource + private PlatformServerService serverService; + + @Resource + private PlatformDomainService domainService; + + @GetMapping("/stats") + @Operation(summary = "获取统计数据", description = "获取首页仪表盘统计数据") + public R getStats() { + DashboardStats stats = new DashboardStats(); + + // 项目统计 + stats.setProjectCount(projectService.count()); + + // 服务器统计 + stats.setServerCount(serverService.count()); + + // 域名统计 + stats.setDomainCount(domainService.count()); + + // 财务统计(收入总额、支出总额、待报销金额) + // 这里简化处理,实际应该用聚合查询 + stats.setIncomeCount(incomeService.count()); + stats.setExpenseCount(expenseService.count()); + stats.setReimbursementCount(reimbursementService.count()); + + return R.ok(stats); + } + + @GetMapping("/overview") + @Operation(summary = "获取概览数据", description = "获取财务概览数据") + public R> getOverview() { + Map overview = new HashMap<>(); + + // 统计数量 + overview.put("totalProjects", projectService.count()); + overview.put("totalServers", serverService.count()); + overview.put("totalDomains", domainService.count()); + overview.put("totalIncomes", incomeService.count()); + overview.put("totalExpenses", expenseService.count()); + overview.put("pendingReimbursements", reimbursementService.count()); + + return R.ok(overview); + } + + @GetMapping("/quick-stats") + @Operation(summary = "快速统计", description = "获取快速统计卡片数据") + public R> getQuickStats() { + List stats = new ArrayList<>(); + + stats.add(new QuickStat("项目总数", projectService.count(), "project", "#1890ff")); + stats.add(new QuickStat("服务器", serverService.count(), "server", "#52c41a")); + stats.add(new QuickStat("域名", domainService.count(), "domain", "#722ed1")); + stats.add(new QuickStat("收入记录", incomeService.count(), "income", "#fa8c16")); + + return R.ok(stats); + } + + @Data + public static class DashboardStats { + private long projectCount; + private long serverCount; + private long domainCount; + private long incomeCount; + private long expenseCount; + private long reimbursementCount; + private BigDecimal totalIncome; + private BigDecimal totalExpense; + private BigDecimal pendingReimbursement; + } + + @Data + public static class QuickStat { + private String title; + private long value; + private String icon; + private String color; + + public QuickStat(String title, long value, String icon, String color) { + this.title = title; + this.value = value; + this.icon = icon; + this.color = color; + } + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/DatabaseController.java b/src/main/java/com/nanxiislet/admin/controller/DatabaseController.java new file mode 100644 index 0000000..e88e52f --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/DatabaseController.java @@ -0,0 +1,284 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.service.OnePanelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 数据库管理控制器 + * 通过1Panel API管理数据库 + */ +@RestController +@RequestMapping("/platform/database") +@Tag(name = "数据库管理", description = "通过1Panel管理数据库") +public class DatabaseController { + + @Resource + private OnePanelService onePanelService; + + /** + * 检查应用安装状态(MySQL/PostgreSQL/Redis等) + */ + @PostMapping("/app/check") + @Operation(summary = "检查应用安装状态") + public R> checkAppInstalled(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + String key = params.get("key").toString(); + String name = params.get("name").toString(); + + Map result = onePanelService.checkAppInstalled(serverId, key, name); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 查询数据库列表 + */ + @PostMapping("/search") + @Operation(summary = "查询数据库列表") + public R> searchDatabases(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + String database = params.get("database").toString(); + int page = params.containsKey("page") ? Integer.parseInt(params.get("page").toString()) : 1; + int pageSize = params.containsKey("pageSize") ? Integer.parseInt(params.get("pageSize").toString()) : 20; + + Map result = onePanelService.searchDatabases(serverId, database, page, pageSize); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 创建数据库 + */ + @PostMapping("/create") + @Operation(summary = "创建数据库") + public R> createDatabase(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 + params.remove("serverId"); + + Map result = onePanelService.createDatabase(serverId, params); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 删除数据库 + */ + @PostMapping("/delete") + @Operation(summary = "删除数据库") + public R deleteDatabase(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 + params.remove("serverId"); + + boolean success = onePanelService.deleteDatabase(serverId, params); + + if (success) { + return R.ok(); + } else { + return R.fail("删除数据库失败"); + } + } + + /** + * 更新数据库描述 + */ + @PostMapping("/description/update") + @Operation(summary = "更新数据库描述") + public R updateDescription(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 + params.remove("serverId"); + + boolean success = onePanelService.updateDatabaseDescription(serverId, params); + + if (success) { + return R.ok(); + } else { + return R.fail("更新数据库描述失败"); + } + } + + /** + * 修改数据库密码 + */ + @PostMapping("/password/change") + @Operation(summary = "修改数据库密码") + public R changePassword(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 + params.remove("serverId"); + + boolean success = onePanelService.changeDatabasePassword(serverId, params); + + if (success) { + return R.ok(); + } else { + return R.fail("修改数据库密码失败"); + } + } + + /** + * 操作应用(启动/停止/重启) + */ + @PostMapping("/app/operate") + @Operation(summary = "操作应用") + public R operateApp(@RequestBody Map params) { + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 (installId, operate) + params.remove("serverId"); + + boolean success = onePanelService.operateApp(serverId, params); + + if (success) { + return R.ok(); + } else { + return R.fail("操作失败"); + } + } + + /** + * 获取数据库字符集排序规则选项 + */ + @PostMapping("/format/options") + @Operation(summary = "获取数据库排序规则选项") + public R> getFormatOptions(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + + Object typeObj = params.get("type"); + String type = typeObj != null ? typeObj.toString() : "mysql"; + + Object dbObj = params.get("database"); + String database = dbObj != null ? dbObj.toString() : type; + + Object formatObj = params.get("format"); + String format = formatObj != null ? formatObj.toString() : null; + + java.util.List options = onePanelService.getDatabaseFormatOptions(serverId, type, database, format); + return R.ok(options); + } + + /** + * 获取应用信息(如Redis) + */ + @PostMapping("/app/info") + @Operation(summary = "获取应用信息") + public R> getAppInfo(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + String appKey = params.get("appKey") != null ? params.get("appKey").toString() : "redis"; + + Map result = onePanelService.getAppInfo(serverId, appKey); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 获取应用版本详情 + */ + @PostMapping("/app/detail") + @Operation(summary = "获取应用版本详情") + public R> getAppDetail(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + Long appId = params.get("appId") != null ? Long.parseLong(params.get("appId").toString()) : null; + String version = params.get("version") != null ? params.get("version").toString() : null; + + if (appId == null || version == null) { + return R.fail("appId和version不能为空"); + } + + Map result = onePanelService.getAppDetail(serverId, appId, version); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 安装应用 + */ + @PostMapping("/app/install") + @Operation(summary = "安装应用") + public R> installApp(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + + // 移除serverId,只保留1Panel需要的参数 + params.remove("serverId"); + + Map result = onePanelService.installApp(serverId, params); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 读取任务日志 + */ + @PostMapping("/task/log") + @Operation(summary = "读取任务日志") + public R> readTaskLog(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + String taskId = params.get("taskId") != null ? params.get("taskId").toString() : ""; + int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1; + int pageSize = params.get("pageSize") != null ? Integer.parseInt(params.get("pageSize").toString()) : 500; + + if (taskId.isEmpty()) { + return R.fail("taskId不能为空"); + } + + Map result = onePanelService.readTaskLog(serverId, taskId, page, pageSize); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } +} + diff --git a/src/main/java/com/nanxiislet/admin/controller/FileController.java b/src/main/java/com/nanxiislet/admin/controller/FileController.java new file mode 100644 index 0000000..138e1c3 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FileController.java @@ -0,0 +1,233 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.service.OnePanelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 文件管理控制器 + */ +@RestController +@RequestMapping("/platform/files") +@Tag(name = "文件管理") +public class FileController { + + @Resource + private OnePanelService onePanelService; + + /** + * 获取文件/目录列表 + */ + @PostMapping("/list") + @Operation(summary = "获取文件列表") + public R> listFiles(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + String path = params.get("path") != null ? params.get("path").toString() : "/"; + + Map result = onePanelService.listFiles(serverId, path); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 创建目录 + */ + @PostMapping("/mkdir") + @Operation(summary = "创建目录") + public R mkdir(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("path") == null) { + return R.fail("path不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String path = params.get("path").toString(); + + Map result = onePanelService.mkdir(serverId, path); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 删除文件/目录 + */ + @PostMapping("/delete") + @Operation(summary = "删除文件") + public R deleteFile(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("path") == null) { + return R.fail("path不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String path = params.get("path").toString(); + Boolean isDir = params.get("isDir") != null && Boolean.parseBoolean(params.get("isDir").toString()); + + Map result = onePanelService.deleteFile(serverId, path, isDir); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 创建文件 + */ + @PostMapping("/create") + @Operation(summary = "创建文件") + public R createFile(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("path") == null) { + return R.fail("path不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String path = params.get("path").toString(); + + Map result = onePanelService.createFile(serverId, path); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 上传文件 + */ + @PostMapping(value = "/upload", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传文件") + public R uploadFile( + @RequestParam("serverId") Long serverId, + @RequestParam("path") String path, + @RequestPart("file") org.springframework.web.multipart.MultipartFile file) { + + if (serverId == null) { + return R.fail("serverId不能为空"); + } + if (path == null) { + return R.fail("path不能为空"); + } + if (file == null || file.isEmpty()) { + return R.fail("文件不能为空"); + } + + Map result = onePanelService.uploadFile(serverId, path, file); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 检查文件是否存在 + */ + @PostMapping("/check") + @Operation(summary = "检查文件是否存在") + public R> checkUploadFiles(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + java.util.List paths = (java.util.List) params.get("paths"); + + java.util.List result = onePanelService.checkFileBatch(serverId, paths); + return R.ok(result); + } + + /** + * 分片上传文件(旧版,兼容) + */ + @PostMapping(value = "/upload/chunk", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "分片上传文件(旧版)") + public R uploadChunk( + @RequestParam("serverId") Long serverId, + @RequestParam("filename") String filename, + @RequestParam("path") String path, + @RequestParam("chunkIndex") int chunkIndex, + @RequestParam("chunkCount") int chunkCount, + @RequestPart("chunk") org.springframework.web.multipart.MultipartFile chunk) { + + return R.ok(onePanelService.uploadFileChunk(serverId, filename, path, chunkIndex, chunkCount, chunk)); + } + + /** + * 分片上传文件(新版,支持分片合并) + */ + @PostMapping(value = "/upload/chunk/v2", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "分片上传文件(v2)") + public R> uploadChunkV2( + @RequestParam("serverId") Long serverId, + @RequestParam("path") String path, + @RequestParam("filename") String filename, + @RequestParam("chunkIndex") int chunkIndex, + @RequestParam("chunkCount") int chunkCount, + @RequestParam("fileSize") long fileSize, + @RequestParam("uploadId") String uploadId, + @RequestPart("chunk") org.springframework.web.multipart.MultipartFile chunk) { + + Map result = onePanelService.uploadChunk(serverId, path, filename, + chunkIndex, chunkCount, fileSize, uploadId, chunk); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 合并已上传的分片 + */ + @PostMapping("/upload/merge") + @Operation(summary = "合并分片") + public R> mergeChunks(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String path = params.get("path").toString(); + String filename = params.get("filename").toString(); + int chunkCount = Integer.parseInt(params.get("chunkCount").toString()); + long fileSize = Long.parseLong(params.get("fileSize").toString()); + String uploadId = params.get("uploadId").toString(); + + Map result = onePanelService.mergeChunks(serverId, path, filename, + chunkCount, fileSize, uploadId); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } +} + diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceAccountController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceAccountController.java new file mode 100644 index 0000000..621cda9 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceAccountController.java @@ -0,0 +1,70 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceAccount; +import com.nanxiislet.admin.service.FinanceAccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 财务账户控制器 + */ +@RestController +@RequestMapping("/finance/account") +@Tag(name = "账户管理", description = "财务账户的增删改查") +public class FinanceAccountController { + + @Resource + private FinanceAccountService accountService; + + @GetMapping("/list") + @Operation(summary = "账户列表") + public R> list() { + return R.ok(accountService.list()); + } + + @GetMapping("/active") + @Operation(summary = "活跃账户列表") + public R> listActive() { + return R.ok(accountService.listActive()); + } + + @GetMapping("/{id}") + @Operation(summary = "账户详情") + public R getById(@PathVariable Long id) { + FinanceAccount account = accountService.getById(id); + if (account == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(account); + } + + @PostMapping + @Operation(summary = "新增账户") + public R create(@Valid @RequestBody FinanceAccount account) { + accountService.save(account); + return R.ok(account); + } + + @PutMapping("/{id}") + @Operation(summary = "更新账户") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceAccount account) { + account.setId(id); + accountService.updateById(account); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除账户") + public R delete(@PathVariable Long id) { + accountService.removeById(id); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceBudgetController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceBudgetController.java new file mode 100644 index 0000000..89e9af0 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceBudgetController.java @@ -0,0 +1,99 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceBudget; +import com.nanxiislet.admin.service.FinanceBudgetService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; + +/** + * 预算管理控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/finance/budget") +@Tag(name = "预算管理", description = "预算记录的增删改查") +public class FinanceBudgetController { + + @Resource + private FinanceBudgetService budgetService; + + @GetMapping("/list") + @Operation(summary = "预算列表", description = "分页查询预算记录") + public R> list(BasePageQuery query) { + Page page = budgetService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "预算详情", description = "根据ID获取预算记录详情") + public R getById(@PathVariable Long id) { + FinanceBudget budget = budgetService.getById(id); + if (budget == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(budget); + } + + @PostMapping + @Operation(summary = "新增预算", description = "创建新的预算记录") + public R create(@Valid @RequestBody FinanceBudget budget) { + // 初始化金额 + if (budget.getUsedAmount() == null) { + budget.setUsedAmount(BigDecimal.ZERO); + } + if (budget.getRemainingAmount() == null) { + budget.setRemainingAmount(budget.getTotalBudget()); + } + if (budget.getUsageRate() == null) { + budget.setUsageRate(BigDecimal.ZERO); + } + if (budget.getStatus() == null) { + budget.setStatus("active"); + } + + budgetService.save(budget); + return R.ok(budget); + } + + @PutMapping("/{id}") + @Operation(summary = "更新预算", description = "更新预算记录") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceBudget budget) { + budget.setId(id); + budgetService.updateById(budget); + budgetService.calculateUsageRate(id); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除预算", description = "删除预算记录") + public R delete(@PathVariable Long id) { + budgetService.removeById(id); + return R.ok(); + } + + @GetMapping("/find") + @Operation(summary = "查找预算", description = "按条件查找预算") + public R findBudget( + @RequestParam Integer year, + @RequestParam String period, + @RequestParam(required = false) Integer quarter, + @RequestParam(required = false) Integer month, + @RequestParam(required = false) Long departmentId, + @RequestParam(required = false) Long projectId) { + FinanceBudget budget = budgetService.findBudget(year, period, quarter, month, departmentId, projectId); + return R.ok(budget); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceExpenseController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceExpenseController.java new file mode 100644 index 0000000..7c231d6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceExpenseController.java @@ -0,0 +1,67 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceExpense; +import com.nanxiislet.admin.service.FinanceExpenseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +/** + * 支出管理控制器 + */ +@RestController +@RequestMapping("/finance/expense") +@Tag(name = "支出管理", description = "支出记录的增删改查") +public class FinanceExpenseController { + + @Resource + private FinanceExpenseService expenseService; + + @GetMapping("/list") + @Operation(summary = "支出列表") + public R> list(BasePageQuery query) { + Page page = expenseService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "支出详情") + public R getById(@PathVariable Long id) { + FinanceExpense expense = expenseService.getById(id); + if (expense == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(expense); + } + + @PostMapping + @Operation(summary = "新增支出") + public R create(@Valid @RequestBody FinanceExpense expense) { + expense.setExpenseNo(expenseService.generateExpenseNo()); + expenseService.save(expense); + return R.ok(expense); + } + + @PutMapping("/{id}") + @Operation(summary = "更新支出") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceExpense expense) { + expense.setId(id); + expenseService.updateById(expense); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除支出") + public R delete(@PathVariable Long id) { + expenseService.removeById(id); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceIncomeController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceIncomeController.java new file mode 100644 index 0000000..5a88a94 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceIncomeController.java @@ -0,0 +1,70 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceIncome; +import com.nanxiislet.admin.service.FinanceIncomeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +/** + * 收入管理控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/finance/income") +@Tag(name = "收入管理", description = "收入记录的增删改查") +public class FinanceIncomeController { + + @Resource + private FinanceIncomeService incomeService; + + @GetMapping("/list") + @Operation(summary = "收入列表", description = "分页查询收入记录") + public R> list(BasePageQuery query) { + Page page = incomeService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "收入详情", description = "根据ID获取收入记录详情") + public R getById(@PathVariable Long id) { + FinanceIncome income = incomeService.getById(id); + if (income == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(income); + } + + @PostMapping + @Operation(summary = "新增收入", description = "创建新的收入记录") + public R create(@Valid @RequestBody FinanceIncome income) { + income.setIncomeNo(incomeService.generateIncomeNo()); + incomeService.save(income); + return R.ok(income); + } + + @PutMapping("/{id}") + @Operation(summary = "更新收入", description = "更新收入记录") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceIncome income) { + income.setId(id); + incomeService.updateById(income); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除收入", description = "删除收入记录") + public R delete(@PathVariable Long id) { + incomeService.removeById(id); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceInvoiceController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceInvoiceController.java new file mode 100644 index 0000000..f590873 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceInvoiceController.java @@ -0,0 +1,93 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceInvoice; +import com.nanxiislet.admin.service.FinanceInvoiceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.Data; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +/** + * 发票管理控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/finance/invoice") +@Tag(name = "发票管理", description = "发票记录的增删改查") +public class FinanceInvoiceController { + + @Resource + private FinanceInvoiceService invoiceService; + + @GetMapping("/list") + @Operation(summary = "发票列表", description = "分页查询发票记录") + public R> list(BasePageQuery query) { + Page page = invoiceService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "发票详情", description = "根据ID获取发票记录详情") + public R getById(@PathVariable Long id) { + FinanceInvoice invoice = invoiceService.getById(id); + if (invoice == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(invoice); + } + + @PostMapping + @Operation(summary = "申请开票", description = "创建新的发票申请") + public R create(@Valid @RequestBody FinanceInvoice invoice) { + invoice.setStatus("pending"); + invoice.setSubmitTime(LocalDate.now()); + invoiceService.save(invoice); + return R.ok(invoice); + } + + @PutMapping("/{id}") + @Operation(summary = "更新发票", description = "更新发票信息") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceInvoice invoice) { + invoice.setId(id); + invoiceService.updateById(invoice); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除发票", description = "删除发票记录") + public R delete(@PathVariable Long id) { + invoiceService.removeById(id); + return R.ok(); + } + + @PostMapping("/{id}/issue") + @Operation(summary = "开具发票", description = "开具发票") + public R issue(@PathVariable Long id) { + invoiceService.issueInvoice(id); + return R.ok(); + } + + @PostMapping("/{id}/reject") + @Operation(summary = "驳回发票", description = "驳回发票申请") + public R reject(@PathVariable Long id, @RequestBody RejectRequest request) { + invoiceService.rejectInvoice(id, request.getReason()); + return R.ok(); + } + + @Data + public static class RejectRequest { + private String reason; + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceReimbursementController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceReimbursementController.java new file mode 100644 index 0000000..ee24c0e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceReimbursementController.java @@ -0,0 +1,79 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceReimbursement; +import com.nanxiislet.admin.service.FinanceReimbursementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +/** + * 报销管理控制器 + */ +@RestController +@RequestMapping("/finance/reimbursement") +@Tag(name = "报销管理", description = "报销记录的增删改查") +public class FinanceReimbursementController { + + @Resource + private FinanceReimbursementService reimbursementService; + + @GetMapping("/list") + @Operation(summary = "报销列表") + public R> list(BasePageQuery query) { + Page page = reimbursementService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "报销详情") + public R getById(@PathVariable Long id) { + FinanceReimbursement reimbursement = reimbursementService.getById(id); + if (reimbursement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(reimbursement); + } + + @PostMapping + @Operation(summary = "新增报销") + public R create(@Valid @RequestBody FinanceReimbursement reimbursement) { + reimbursement.setReimbursementNo(reimbursementService.generateReimbursementNo()); + reimbursementService.save(reimbursement); + return R.ok(reimbursement); + } + + @PutMapping("/{id}") + @Operation(summary = "更新报销") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceReimbursement reimbursement) { + reimbursement.setId(id); + reimbursementService.updateById(reimbursement); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除报销") + public R delete(@PathVariable Long id) { + reimbursementService.removeById(id); + return R.ok(); + } + + @PostMapping("/{id}/submit") + @Operation(summary = "提交审批") + public R submit(@PathVariable Long id) { + FinanceReimbursement reimbursement = reimbursementService.getById(id); + if (reimbursement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + reimbursement.setStatus("pending"); + reimbursementService.updateById(reimbursement); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/FinanceSettlementController.java b/src/main/java/com/nanxiislet/admin/controller/FinanceSettlementController.java new file mode 100644 index 0000000..b4adb31 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/FinanceSettlementController.java @@ -0,0 +1,109 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceSettlement; +import com.nanxiislet.admin.service.FinanceSettlementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.Data; +import org.springframework.web.bind.annotation.*; + +/** + * 结算管理控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/finance/settlement") +@Tag(name = "结算管理", description = "结算记录的增删改查") +public class FinanceSettlementController { + + @Resource + private FinanceSettlementService settlementService; + + @GetMapping("/list") + @Operation(summary = "结算列表", description = "分页查询结算记录") + public R> list(BasePageQuery query) { + Page page = settlementService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "结算详情", description = "根据ID获取结算记录详情") + public R getById(@PathVariable Long id) { + FinanceSettlement settlement = settlementService.getById(id); + if (settlement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(settlement); + } + + @PostMapping + @Operation(summary = "新增结算", description = "创建新的结算记录") + public R create(@Valid @RequestBody FinanceSettlement settlement) { + // 计算金额 + settlementService.calculateAmounts(settlement); + + // 默认状态 + if (settlement.getStatus() == null) { + settlement.setStatus("pending"); + } + if (settlement.getInvoiceStatus() == null) { + settlement.setInvoiceStatus("none"); + } + + settlementService.save(settlement); + return R.ok(settlement); + } + + @PutMapping("/{id}") + @Operation(summary = "更新结算", description = "更新结算记录") + public R update(@PathVariable Long id, @Valid @RequestBody FinanceSettlement settlement) { + settlement.setId(id); + // 重新计算金额 + settlementService.calculateAmounts(settlement); + settlementService.updateById(settlement); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除结算", description = "删除结算记录") + public R delete(@PathVariable Long id) { + settlementService.removeById(id); + return R.ok(); + } + + @PostMapping("/{id}/approve") + @Operation(summary = "审核通过", description = "审核通过结算单") + public R approve(@PathVariable Long id) { + settlementService.approve(id); + return R.ok(); + } + + @PostMapping("/{id}/reject") + @Operation(summary = "驳回", description = "驳回结算单") + public R reject(@PathVariable Long id, @RequestBody RejectRequest request) { + settlementService.reject(id, request.getReason()); + return R.ok(); + } + + @PostMapping("/{id}/confirm-payment") + @Operation(summary = "确认打款", description = "确认打款完成") + public R confirmPayment(@PathVariable Long id) { + settlementService.confirmPayment(id); + return R.ok(); + } + + @Data + public static class RejectRequest { + private String reason; + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/HealthController.java b/src/main/java/com/nanxiislet/admin/controller/HealthController.java new file mode 100644 index 0000000..523254d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/HealthController.java @@ -0,0 +1,51 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@Tag(name = "系统", description = "系统相关接口") +public class HealthController { + + @Value("${spring.application.name:nanxiislet-admin}") + private String applicationName; + + @Value("${server.port:8080}") + private String port; + + @GetMapping("/health") + @Operation(summary = "健康检查", description = "检查服务健康状态") + public R> health() { + Map result = new HashMap<>(); + result.put("status", "UP"); + result.put("application", applicationName); + result.put("port", port); + result.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + return R.ok(result); + } + + @GetMapping("/") + @Operation(summary = "首页", description = "API首页") + public R> index() { + Map result = new HashMap<>(); + result.put("name", "Nanxiislet Admin API"); + result.put("version", "1.0.0"); + result.put("docs", "/doc.html"); + return R.ok(result); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/PlatformCertificateController.java b/src/main/java/com/nanxiislet/admin/controller/PlatformCertificateController.java new file mode 100644 index 0000000..029c423 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/PlatformCertificateController.java @@ -0,0 +1,138 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.CertificateApplyRequest; +import com.nanxiislet.admin.dto.CertificateApplyResult; +import com.nanxiislet.admin.entity.PlatformCertificate; +import com.nanxiislet.admin.service.PlatformCertificateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 证书管理控制器 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@RestController +@RequestMapping("/platform/certificate") +@Tag(name = "证书管理", description = "管理SSL/TLS证书,整合1Panel证书功能") +public class PlatformCertificateController { + + @Resource + private PlatformCertificateService certificateService; + + @Resource + private com.nanxiislet.admin.service.PlatformServerService serverService; + + // ==================== 证书 CRUD ==================== + + @GetMapping("/list") + @Operation(summary = "证书列表", description = "获取指定服务器的证书列表") + public R> list( + @RequestParam(required = false) Long serverId, + BasePageQuery query) { + Page page = certificateService.listPage(serverId, query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "证书详情", description = "获取证书详情,包含证书内容和私钥") + public R getDetail(@PathVariable Long id) { + PlatformCertificate cert = certificateService.getCertificateDetail(id); + if (cert == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(cert); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除证书", description = "删除证书,同时从1Panel删除") + public R delete(@PathVariable Long id) { + boolean success = certificateService.deleteCertificate(id); + if (!success) { + throw new BusinessException("删除失败"); + } + return R.ok(); + } + + @PutMapping("/{id}/settings") + @Operation(summary = "更新证书设置", description = "更新自动续签和备注") + public R updateSettings( + @PathVariable Long id, + @RequestParam(required = false) Boolean autoRenew, + @RequestParam(required = false) String description) { + certificateService.updateCertificateSettings(id, autoRenew, description); + return R.ok(); + } + + // ==================== 证书申请 ==================== + + @PostMapping("/apply") + @Operation(summary = "申请证书", description = "调用1Panel API申请SSL证书") + public R apply(@Valid @RequestBody CertificateApplyRequest request) { + CertificateApplyResult result = certificateService.applyCertificate(request); + return R.ok(result); + } + + // ==================== 证书同步 ==================== + + @PostMapping("/sync/{serverId}") + @Operation(summary = "同步证书", description = "从1Panel同步证书列表到本地数据库") + public R> sync(@PathVariable Long serverId) { + int count = certificateService.syncCertificatesFromPanel(serverId); + return R.ok(Map.of("syncCount", count, "message", "同步完成,共 " + count + " 个证书")); + } + + // ==================== 1Panel 账户查询 ==================== + + @GetMapping("/acme-accounts/{serverId}") + @Operation(summary = "获取Acme账户列表", description = "从1Panel获取Acme账户列表") + public R>> getAcmeAccounts(@PathVariable Long serverId) { + return R.ok(certificateService.getAcmeAccounts(serverId)); + } + + @GetMapping("/dns-accounts/{serverId}") + @Operation(summary = "获取DNS账户列表", description = "从1Panel获取DNS账户列表") + public R>> getDnsAccounts(@PathVariable Long serverId) { + return R.ok(certificateService.getDnsAccounts(serverId)); + } + + @GetMapping("/websites/{serverId}") + @Operation(summary = "获取网站列表", description = "从1Panel获取网站列表") + public R>> getWebsites(@PathVariable Long serverId) { + return R.ok(certificateService.getWebsites(serverId)); + } + + // ==================== 调试接口 ==================== + + @GetMapping("/debug/server/{serverId}") + @Operation(summary = "检查服务器1Panel配置", description = "用于调试:检查服务器是否正确配置了1Panel API") + public R> debugServerConfig(@PathVariable Long serverId) { + var server = serverService.getById(serverId); + if (server == null) { + return R.ok(Map.of("error", "服务器不存在", "serverId", serverId)); + } + + return R.ok(Map.of( + "serverId", serverId, + "serverName", server.getName() != null ? server.getName() : "", + "ip", server.getIp() != null ? server.getIp() : "", + "panelUrl", server.getPanelUrl() != null ? server.getPanelUrl() : "(未配置)", + "panelPort", server.getPanelPort() != null ? server.getPanelPort() : 42588, + "panelApiKeyConfigured", server.getPanelApiKey() != null && !server.getPanelApiKey().isEmpty(), + "panelApiKeyLength", server.getPanelApiKey() != null ? server.getPanelApiKey().length() : 0 + )); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/PlatformDomainController.java b/src/main/java/com/nanxiislet/admin/controller/PlatformDomainController.java new file mode 100644 index 0000000..9c38848 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/PlatformDomainController.java @@ -0,0 +1,275 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.DomainDeployRequest; +import com.nanxiislet.admin.dto.DomainDeployResult; +import com.nanxiislet.admin.dto.DomainStatsDTO; +import com.nanxiislet.admin.entity.PlatformDomain; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.service.PlatformDomainService; +import com.nanxiislet.admin.service.PlatformServerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 域名管理控制器 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@RestController +@RequestMapping("/platform/domain") +@Tag(name = "域名管理", description = "域名的增删改查及部署") +public class PlatformDomainController { + + @Resource + private PlatformDomainService domainService; + + @Resource + private PlatformServerService serverService; + + // ==================== 基础 CRUD ==================== + + @GetMapping("/list") + @Operation(summary = "域名列表") + public R> list(com.nanxiislet.admin.dto.DomainQueryDTO query) { + Page page = domainService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/all") + @Operation(summary = "所有域名") + public R> listAll() { + return R.ok(domainService.list()); + } + + @GetMapping("/{id}") + @Operation(summary = "域名详情") + public R getById(@PathVariable Long id) { + PlatformDomain domain = domainService.getById(id); + if (domain == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(domain); + } + + @PostMapping + @Operation(summary = "新增域名") + public R create(@Valid @RequestBody PlatformDomain domain) { + // 检查域名是否已存在 + PlatformDomain existing = domainService.getByDomain(domain.getDomain()); + if (existing != null) { + throw new BusinessException("域名已存在"); + } + + // 设置默认值 + if (domain.getStatus() == null) { + domain.setStatus("pending"); + } + if (domain.getDnsStatus() == null) { + domain.setDnsStatus("checking"); + } + if (domain.getSslStatus() == null) { + domain.setSslStatus("none"); + } + if (domain.getDeployStatus() == null) { + domain.setDeployStatus("not_deployed"); + } + if (domain.getPort() == null) { + domain.setPort(80); + } + + // 自动填充服务器信息 + if (domain.getServerId() != null) { + PlatformServer server = serverService.getById(domain.getServerId()); + if (server != null) { + domain.setServerName(server.getName()); + domain.setServerIp(server.getIp()); + } + } + + // 自动生成别名和站点路径 + if (!StringUtils.hasText(domain.getAlias())) { + domain.setAlias(domain.getDomain().replace(".", "_")); + } + if (!StringUtils.hasText(domain.getSitePath())) { + domain.setSitePath("/opt/1panel/www/sites/" + domain.getDomain() + "/index"); + } + + // 如果绑定了SSL证书,自动设置SSL状态和开启HTTPS + if (domain.getCertificateId() != null) { + domain.setSslStatus("valid"); + domain.setEnableHttps(true); + } + + domainService.save(domain); + return R.ok(domain); + } + + @PutMapping("/{id}") + @Operation(summary = "更新域名") + public R update(@PathVariable Long id, @Valid @RequestBody PlatformDomain domain) { + PlatformDomain existing = domainService.getById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + domain.setId(id); + + // 自动填充服务器信息 + if (domain.getServerId() != null && !domain.getServerId().equals(existing.getServerId())) { + PlatformServer server = serverService.getById(domain.getServerId()); + if (server != null) { + domain.setServerName(server.getName()); + domain.setServerIp(server.getIp()); + } + } + + // 处理SSL和HTTPS状态 + // 只有在用户未明确设置 enableHttps 时,才自动根据证书设置 + if (domain.getEnableHttps() == null) { + // 用户未传入 enableHttps,根据证书自动判断 + if (domain.getCertificateId() != null) { + domain.setSslStatus("valid"); + domain.setEnableHttps(true); + } + } else if (Boolean.FALSE.equals(domain.getEnableHttps())) { + // 用户明确关闭HTTPS + domain.setSslStatus("none"); + } else if (Boolean.TRUE.equals(domain.getEnableHttps()) && domain.getCertificateId() != null) { + // 用户开启HTTPS且有证书 + domain.setSslStatus("valid"); + } + + domainService.updateById(domain); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除域名") + public R delete(@PathVariable Long id) { + domainService.removeById(id); + return R.ok(); + } + + // ==================== 统计接口 ==================== + + @GetMapping("/stats") + @Operation(summary = "域名统计信息") + public R getStats() { + return R.ok(domainService.getDomainStats()); + } + + // ==================== 部署相关接口 ==================== + + @PostMapping("/deploy") + @Operation(summary = "部署域名到1Panel", description = "包含创建网站、申请证书、配置HTTPS") + public R deploy(@Valid @RequestBody DomainDeployRequest request) { + DomainDeployResult result = domainService.deployDomain(request); + return R.ok(result); + } + + @PostMapping("/undeploy/{id}") + @Operation(summary = "从1Panel删除部署") + public R undeploy(@PathVariable Long id) { + domainService.undeployDomain(id); + return R.ok(); + } + + @PostMapping("/{id}/check-dns") + @Operation(summary = "检查域名DNS解析状态") + public R> checkDns(@PathVariable Long id) { + Map result = domainService.checkDomainDns(id); + return R.ok(result); + } + + @PostMapping("/{id}/sync") + @Operation(summary = "从1Panel同步域名信息") + public R syncFromPanel(@PathVariable Long id) { + PlatformDomain domain = domainService.syncDomainFromPanel(id); + return R.ok(domain); + } + + @PostMapping("/sync-from-certificates/{serverId}") + @Operation(summary = "从证书同步域名", description = "将证书表中的域名同步到域名表") + public R> syncFromCertificates(@PathVariable Long serverId) { + int count = domainService.syncDomainsFromCertificates(serverId); + return R.ok(Map.of("syncCount", count, "message", "同步完成,共 " + count + " 个域名")); + } + + @PostMapping("/{id}/deploy-runtime") + @Operation(summary = "部署运行环境到1Panel", description = "创建运行时类型的网站") + public R deployRuntime(@PathVariable Long id) { + DomainDeployResult result = domainService.deployRuntime(id); + return R.ok(result); + } + + // ==================== Nginx配置相关 ==================== + + @Resource + private com.nanxiislet.admin.service.OnePanelService onePanelService; + + @GetMapping("/{id}/nginx-config") + @Operation(summary = "获取域名Nginx配置") + public R> getNginxConfig(@PathVariable Long id) { + PlatformDomain domain = domainService.getById(id); + if (domain == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (domain.getServerId() == null || domain.getPanelWebsiteId() == null) { + throw new BusinessException("域名未部署或未绑定服务器"); + } + + Map config = onePanelService.getWebsiteNginxConfig(domain.getServerId(), domain.getPanelWebsiteId()); + // 添加域名信息 + config.put("domain", domain.getDomain()); + return R.ok(config); + } + + @PutMapping("/{id}/nginx-config") + @Operation(summary = "保存域名Nginx配置") + public R saveNginxConfig(@PathVariable Long id, @RequestBody Map params) { + PlatformDomain domain = domainService.getById(id); + if (domain == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (domain.getServerId() == null || domain.getPanelWebsiteId() == null) { + throw new BusinessException("域名未部署或未绑定服务器"); + } + + String content = (String) params.get("content"); + if (content == null || content.isEmpty()) { + throw new BusinessException("配置内容不能为空"); + } + + boolean success = onePanelService.saveWebsiteNginxConfig(domain.getServerId(), domain.getPanelWebsiteId(), content); + return success ? R.ok(true) : R.fail("保存失败"); + } + + @PostMapping("/{id}/nginx-reload") + @Operation(summary = "重载Nginx") + public R reloadNginx(@PathVariable Long id) { + PlatformDomain domain = domainService.getById(id); + if (domain == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (domain.getServerId() == null) { + throw new BusinessException("域名未绑定服务器"); + } + + boolean success = onePanelService.reloadNginx(domain.getServerId()); + return success ? R.ok(true) : R.fail("重载失败"); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/PlatformProjectController.java b/src/main/java/com/nanxiislet/admin/controller/PlatformProjectController.java new file mode 100644 index 0000000..4812d4b --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/PlatformProjectController.java @@ -0,0 +1,418 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.DeployRequest; +import com.nanxiislet.admin.dto.DeployResult; +import com.nanxiislet.admin.entity.PlatformProject; +import com.nanxiislet.admin.service.OnePanelService; +import com.nanxiislet.admin.service.PlatformProjectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +/** + * 项目管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/platform/project") +@Tag(name = "项目管理", description = "项目的增删改查和部署") +public class PlatformProjectController { + + @Resource + private PlatformProjectService projectService; + + @Resource + private OnePanelService onePanelService; + + @Resource + private com.nanxiislet.admin.service.PlatformDomainService domainService; + + @Resource + private com.nanxiislet.admin.service.SysMenuService menuService; + + @GetMapping("/list") + @Operation(summary = "项目列表") + public R> list(BasePageQuery query) { + Page page = projectService.listPage(query); + // 填充菜单数量 + fillMenuCount(page.getRecords()); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/all") + @Operation(summary = "所有项目") + public R> listAll() { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + // 过滤掉门户类项目 + wrapper.ne(PlatformProject::getSystemType, "portal"); + // 确保按照排序字段排序 + wrapper.orderByAsc(PlatformProject::getSort); + + List list = projectService.list(wrapper); + // 填充菜单数量 + fillMenuCount(list); + return R.ok(list); + } + + @GetMapping("/integrated") + @Operation(summary = "获取集成到框架的业务项目", description = "返回 systemType=admin 且 integrateToFramework=true 的项目") + public R> listIntegratedProjects() { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + wrapper.eq(PlatformProject::getSystemType, "admin") + .eq(PlatformProject::getIntegrateToFramework, true) + .eq(PlatformProject::getStatus, "active") + .orderByAsc(PlatformProject::getSort); + List list = projectService.list(wrapper); + + // 填充菜单数量 + fillMenuCount(list); + + return R.ok(list); + } + + /** + * 填充菜单数量 + */ + private void fillMenuCount(List projectList) { + if (projectList == null || projectList.isEmpty()) { + return; + } + for (PlatformProject project : projectList) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper menuWrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + + // 判定是否为主框架 NanxiAdmin + // 逻辑:简称或Code匹配(忽略大小写和空格),或者 ID 为 9 (从截图看是9,作为兜底) + String shortName = project.getShortName() != null ? project.getShortName().trim() : ""; + String code = project.getCode() != null ? project.getCode().trim() : ""; + + boolean isMainProject = "NanxiAdmin".equalsIgnoreCase(shortName) + || "NanxiAdmin".equalsIgnoreCase(code) + || Long.valueOf(9).equals(project.getId()); + + if (isMainProject) { + // 主框架:统计 project_id 为 null 或等于其 id + menuWrapper.and(w -> w.eq(com.nanxiislet.admin.entity.SysMenu::getProjectId, project.getId()) + .or() + .isNull(com.nanxiislet.admin.entity.SysMenu::getProjectId)); + } else { + // 其他项目,只统计其 id + menuWrapper.eq(com.nanxiislet.admin.entity.SysMenu::getProjectId, project.getId()); + } + // 只统计菜单和目录,不统计按钮 + menuWrapper.in(com.nanxiislet.admin.entity.SysMenu::getType, "directory", "menu"); + + long count = menuService.count(menuWrapper); + project.setMenuCount(count); + + log.info("Project: {}, ID: {}, IsMain: {}, MenuCount: {}", project.getShortName(), project.getId(), isMainProject, count); + } + } + + @GetMapping("/{id}") + @Operation(summary = "项目详情") + public R getById(@PathVariable Long id) { + PlatformProject project = projectService.getById(id); + if (project == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(project); + } + + @PostMapping + @Operation(summary = "新增项目") + public R create(@Valid @RequestBody PlatformProject project) { + projectService.save(project); + return R.ok(project); + } + + @PutMapping("/{id}") + @Operation(summary = "更新项目") + public R update(@PathVariable Long id, @Valid @RequestBody PlatformProject project) { + project.setId(id); + + // 如果状态发生改变,尝试同步更新 1Panel 网站状态 + if (project.getStatus() != null) { + PlatformProject existingProject = projectService.getById(id); + if (existingProject != null && !project.getStatus().equals(existingProject.getStatus())) { + // 判断是否已部署且绑定了 1Panel 网站 + if (existingProject.getPanelWebsiteId() != null && existingProject.getServerId() != null) { + String operate = "active".equals(project.getStatus()) ? "start" : "stop"; + onePanelService.operateWebsite(existingProject.getServerId(), existingProject.getPanelWebsiteId(), operate); + } + } + } + + projectService.updateById(project); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除项目") + public R delete(@PathVariable Long id) { + PlatformProject project = projectService.getById(id); + if (project != null && project.getDomainId() != null) { + // 解除域名绑定并重置状态 + com.nanxiislet.admin.entity.PlatformDomain domain = domainService.getById(project.getDomainId()); + if (domain != null) { + // 如果已部署且绑定了 1Panel 网站,则调用 1Panel 接口删除网站 + if (domain.getPanelWebsiteId() != null && domain.getServerId() != null) { + try { + onePanelService.deleteWebsite(domain.getServerId(), domain.getPanelWebsiteId()); + } catch (Exception e) { + // 忽略删除网站失败,继续执行解绑逻辑 + } + } + + domain.setProjectId(null); + domain.setProjectName(null); + domain.setStatus("pending"); + domain.setDeployStatus("pending"); + domain.setPanelWebsiteId(null); + domain.setPanelSslId(null); + domain.setDnsStatus("pending"); + domain.setSslStatus("pending"); + domain.setEnableHttps(false); + domainService.updateById(domain); + + // 尝试重新检测 DNS + try { + domainService.checkDomainDns(domain.getId()); + } catch (Exception e) { + // 忽略 DNS 检测错误 + } + } + } + + projectService.removeById(id); + return R.ok(); + } + + // ==================== 部署相关接口 ==================== + + @PostMapping("/deploy") + @Operation(summary = "部署项目到服务器", description = "通过1Panel API将项目部署到服务器") + public R deploy(@Valid @RequestBody DeployRequest request) { + DeployResult result = onePanelService.deployProject(request); + return R.ok(result); + } + + @GetMapping("/{serverId}/websites") + @Operation(summary = "获取服务器网站列表", description = "从1Panel获取指定服务器的网站列表") + public R getWebsites(@PathVariable Long serverId) { + String websites = onePanelService.getWebsites(serverId); + return R.ok(websites); + } + + @GetMapping("/{serverId}/certificates") + @Operation(summary = "获取服务器证书列表", description = "从1Panel获取指定服务器的SSL证书列表") + public R getCertificates(@PathVariable Long serverId) { + String certificates = onePanelService.getCertificates(serverId); + return R.ok(certificates); + } + + @GetMapping("/check-deploy/{projectId}") + @Operation(summary = "检查项目部署状态", description = "检查项目对应的网站是否已在1Panel中存在") + public R> checkDeployStatus(@PathVariable Long projectId) { + PlatformProject project = projectService.getById(projectId); + if (project == null) { + return R.fail("项目不存在"); + } + + Map result = new java.util.HashMap<>(); + result.put("projectId", projectId); + result.put("domain", project.getDomain()); + + if (project.getServerId() == null || project.getDomain() == null || project.getDomain().isEmpty()) { + result.put("deployed", false); + result.put("websiteId", null); + result.put("message", "项目未配置服务器或域名"); + return R.ok(result); + } + + // 获取网站详细状态 + Map websiteStatus = onePanelService.checkWebsiteStatus(project.getServerId(), project.getDomain()); + boolean exists = Boolean.TRUE.equals(websiteStatus.get("exists")); + + result.put("deployed", exists); + result.put("websiteId", websiteStatus.get("id")); + result.put("websiteStatus", websiteStatus.get("status")); + result.put("sslStatus", websiteStatus.get("sslStatus")); + result.put("protocol", websiteStatus.get("protocol")); + result.put("message", exists ? "已部署" : "未部署"); + + // 更新项目状态到数据库 + if (exists) { + Long websiteId = (Long) websiteStatus.get("id"); + String status = (String) websiteStatus.get("status"); + String sslStatus = (String) websiteStatus.get("sslStatus"); + String protocol = (String) websiteStatus.get("protocol"); + + boolean needUpdate = false; + + // 更新 panelWebsiteId + if (websiteId != null && (project.getPanelWebsiteId() == null || !project.getPanelWebsiteId().equals(websiteId))) { + project.setPanelWebsiteId(websiteId); + needUpdate = true; + } + + // 更新部署状态 + if (!"success".equals(project.getLastDeployStatus())) { + project.setLastDeployStatus("success"); + needUpdate = true; + } + + // 更新项目状态(Running -> active/启用) + if ("Running".equals(status) && !"active".equals(project.getStatus())) { + project.setStatus("active"); + needUpdate = true; + } + + // 更新 HTTPS 状态 + if ("HTTPS".equals(protocol) && !Boolean.TRUE.equals(project.getEnableHttps())) { + project.setEnableHttps(true); + needUpdate = true; + } + + // 更新部署路径 + String sitePath = (String) websiteStatus.get("sitePath"); + if (sitePath != null) { + // 强制追加 /index 目录 + if (!sitePath.endsWith("/index")) { + if (sitePath.endsWith("/")) { + sitePath = sitePath + "index"; + } else { + sitePath = sitePath + "/index"; + } + } + if (!sitePath.equals(project.getDeployPath())) { + log.info("Updating Project {} deployPath to {}", project.getId(), sitePath); // Log + project.setDeployPath(sitePath); + needUpdate = true; + } + } + + if (needUpdate) { + projectService.updateById(project); + } + } + + return R.ok(result); + } + + // ==================== 文件上传相关接口 ==================== + + @PostMapping("/upload/check") + @Operation(summary = "检查文件是否存在") + public R> checkUploadFiles(@RequestBody Map params) { + Long projectId = Long.valueOf(params.get("projectId").toString()); + List paths = (List) params.get("paths"); + + PlatformProject project = projectService.getById(projectId); + if (project == null || project.getServerId() == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + log.info("Checking files for Project {}: {}", projectId, paths); // Log input + List result = onePanelService.checkFileBatch(project.getServerId(), paths); + log.info("Check result: {}", result); // Log output + return R.ok(result); + } + + @PostMapping("/upload/prepare") + @Operation(summary = "准备上传(已废弃,保留向后兼容)", description = "由于 1Panel upload 接口支持 overwrite 参数,此接口已不再需要预先清理文件") + @Deprecated + public R prepareUpload(@RequestBody Map params) { + // upload 接口支持 overwrite=true,不再需要预先清理文件 + return R.ok(true); + } + + @PostMapping(value = "/upload/chunk", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "分片上传文件") + public R uploadChunk( + @RequestParam("projectId") Long projectId, + @RequestParam("filename") String filename, + @RequestParam("path") String path, + @RequestParam("chunkIndex") int chunkIndex, + @RequestParam("chunkCount") int chunkCount, + @RequestPart("chunk") org.springframework.web.multipart.MultipartFile chunk) { + + PlatformProject project = projectService.getById(projectId); + if (project == null || project.getServerId() == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + return R.ok(onePanelService.uploadFileChunk(project.getServerId(), filename, path, chunkIndex, chunkCount, chunk)); + } + + // ==================== Nginx配置相关 ==================== + + @GetMapping("/{projectId}/nginx-config") + @Operation(summary = "获取项目Nginx配置") + public R> getNginxConfig(@PathVariable Long projectId) { + PlatformProject project = projectService.getById(projectId); + if (project == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (project.getServerId() == null || project.getPanelWebsiteId() == null) { + throw new BusinessException("项目未部署或未绑定服务器"); + } + + Map config = onePanelService.getWebsiteNginxConfig(project.getServerId(), project.getPanelWebsiteId()); + // 添加域名信息 + config.put("domain", project.getDomain()); + return R.ok(config); + } + + @PutMapping("/{projectId}/nginx-config") + @Operation(summary = "保存项目Nginx配置") + public R saveNginxConfig(@PathVariable Long projectId, @RequestBody Map params) { + PlatformProject project = projectService.getById(projectId); + if (project == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (project.getServerId() == null || project.getPanelWebsiteId() == null) { + throw new BusinessException("项目未部署或未绑定服务器"); + } + + String content = (String) params.get("content"); + if (content == null || content.isEmpty()) { + throw new BusinessException("配置内容不能为空"); + } + + boolean success = onePanelService.saveWebsiteNginxConfig(project.getServerId(), project.getPanelWebsiteId(), content); + return success ? R.ok(true) : R.fail("保存失败"); + } + + @PostMapping("/{projectId}/nginx-reload") + @Operation(summary = "重载Nginx") + public R reloadNginx(@PathVariable Long projectId) { + PlatformProject project = projectService.getById(projectId); + if (project == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + if (project.getServerId() == null) { + throw new BusinessException("项目未绑定服务器"); + } + + boolean success = onePanelService.reloadNginx(project.getServerId()); + return success ? R.ok(true) : R.fail("重载失败"); + } + +} + diff --git a/src/main/java/com/nanxiislet/admin/controller/PlatformServerController.java b/src/main/java/com/nanxiislet/admin/controller/PlatformServerController.java new file mode 100644 index 0000000..c8b65c0 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/PlatformServerController.java @@ -0,0 +1,135 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.ServerInfoDto; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.service.PlatformServerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 服务器管理控制器 + */ +@RestController +@RequestMapping("/platform/server") +@Tag(name = "服务器管理", description = "服务器的增删改查") +public class PlatformServerController { + + @Resource + private PlatformServerService serverService; + + @GetMapping("/list") + @Operation(summary = "服务器列表(分页)") + public R> list(BasePageQuery query) { + Page page = serverService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/all") + @Operation(summary = "所有服务器(基础数据,支持筛选)") + public R> listAll( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String type + ) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + + if (cn.hutool.core.util.StrUtil.isNotBlank(keyword)) { + wrapper.and(w -> w + .like(PlatformServer::getName, keyword) + .or().like(PlatformServer::getIp, keyword) + ); + } + + if (cn.hutool.core.util.StrUtil.isNotBlank(status)) { + wrapper.eq(PlatformServer::getStatus, status); + } + + if (cn.hutool.core.util.StrUtil.isNotBlank(type)) { + wrapper.eq(PlatformServer::getType, type); + } + + wrapper.orderByDesc(PlatformServer::getCreatedAt); + + return R.ok(serverService.list(wrapper)); + } + + @GetMapping("/{id}") + @Operation(summary = "服务器详情(基础数据)") + public R getById(@PathVariable Long id) { + PlatformServer server = serverService.getById(id); + if (server == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(server); + } + + @GetMapping("/{id}/status") + @Operation(summary = "获取服务器实时状态(从1Panel获取CPU/内存/磁盘使用情况)") + public R getServerStatus(@PathVariable Long id) { + ServerInfoDto dto = serverService.getServerStatus(id); + if (dto == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(dto); + } + + @PostMapping("/{id}/refresh") + @Operation(summary = "刷新服务器状态(同 /{id}/status)") + public R refreshStatus(@PathVariable Long id) { + ServerInfoDto dto = serverService.getServerStatus(id); + if (dto == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + return R.ok(dto); + } + + @PostMapping + @Operation(summary = "新增服务器") + public R create(@Valid @RequestBody PlatformServer server) { + serverService.save(server); + return R.ok(server); + } + + @PutMapping("/{id}") + @Operation(summary = "更新服务器") + public R update(@PathVariable Long id, @Valid @RequestBody PlatformServer server) { + server.setId(id); + serverService.updateById(server); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除服务器") + public R delete(@PathVariable Long id) { + // 检查是否有绑定的项目 + List bindingProjects = serverService.getBindingProjects(id); + if (!bindingProjects.isEmpty()) { + String projectNames = String.join("、", bindingProjects); + return R.fail("无法删除服务器,以下项目正在使用该服务器:" + projectNames + ",请先删除或修改这些项目的服务器配置"); + } + + // 删除该服务器绑定的域名 + serverService.deleteBindingDomains(id); + + // 删除该服务器绑定的证书 + serverService.deleteBindingCertificates(id); + + // 删除服务器 + serverService.removeById(id); + return R.ok(); + } + +} + + diff --git a/src/main/java/com/nanxiislet/admin/controller/RuntimeController.java b/src/main/java/com/nanxiislet/admin/controller/RuntimeController.java new file mode 100644 index 0000000..55ff402 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/RuntimeController.java @@ -0,0 +1,297 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.service.OnePanelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 运行时管理控制器 + */ +@RestController +@RequestMapping("/platform/runtime") +@Tag(name = "运行时管理") +public class RuntimeController { + + @Resource + private OnePanelService onePanelService; + + + /** + * 搜索运行时列表 + */ + @PostMapping("/search") + @Operation(summary = "搜索运行时列表") + public R> searchRuntimes(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + String type = params.get("type") != null ? params.get("type").toString() : "node"; + int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1; + int pageSize = params.get("pageSize") != null ? Integer.parseInt(params.get("pageSize").toString()) : 20; + + Map result = onePanelService.searchRuntimes(serverId, type, page, pageSize); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 同步运行时状态 + */ + @PostMapping("/sync") + @Operation(summary = "同步运行时状态") + public R syncRuntimes(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + + Map result = onePanelService.syncRuntimes(serverId); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 操作运行时(启动/停止/重启) + */ + @PostMapping("/operate") + @Operation(summary = "操作运行时") + public R operateRuntime(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("id") == null) { + return R.fail("id不能为空"); + } + if (params.get("operate") == null) { + return R.fail("operate不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + Long id = Long.parseLong(params.get("id").toString()); + String operate = params.get("operate").toString(); + + Map result = onePanelService.operateRuntime(serverId, id, operate); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 删除运行时 + */ + @PostMapping("/delete") + @Operation(summary = "删除运行时") + public R deleteRuntime(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("id") == null) { + return R.fail("id不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + Long id = Long.parseLong(params.get("id").toString()); + boolean forceDelete = params.get("forceDelete") != null && Boolean.parseBoolean(params.get("forceDelete").toString()); + boolean deleteFolder = params.get("deleteFolder") != null && Boolean.parseBoolean(params.get("deleteFolder").toString()); + String codeDir = params.get("codeDir") != null ? params.get("codeDir").toString() : null; + + Map result = onePanelService.deleteRuntime(serverId, id, forceDelete, deleteFolder, codeDir); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + /** + * 搜索运行时应用列表 + */ + @PostMapping("/apps/search") + @Operation(summary = "搜索运行时应用列表") + public R> searchRuntimeApps(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + Long serverId = Long.parseLong(params.get("serverId").toString()); + String type = params.get("type") != null ? params.get("type").toString() : "node"; + int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1; + int pageSize = params.get("pageSize") != null ? Integer.parseInt(params.get("pageSize").toString()) : 20; + + Map result = onePanelService.searchRuntimeApps(serverId, type, page, pageSize); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 获取运行时版本详情 + */ + @PostMapping("/detail") + @Operation(summary = "获取运行时版本详情") + public R> getRuntimeDetail(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("appId") == null) { + return R.fail("appId不能为空"); + } + if (params.get("version") == null) { + return R.fail("version不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + Long appId = Long.parseLong(params.get("appId").toString()); + String version = params.get("version").toString(); + + Map result = onePanelService.getRuntimeDetail(serverId, appId, version); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 获取应用信息(包含版本列表) + */ + @PostMapping("/app/info") + @Operation(summary = "获取应用信息") + public R> getAppInfo(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("appKey") == null) { + return R.fail("appKey不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String appKey = params.get("appKey").toString(); + + Map result = onePanelService.getAppInfo(serverId, appKey); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + /** + * 创建运行时 + */ + @PostMapping("/create") + @Operation(summary = "创建运行时") + public R createRuntime(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + params.remove("serverId"); + + Map result = onePanelService.createRuntime(serverId, params); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + + + /** + * 获取容器日志 + */ + @PostMapping("/container/log") + @Operation(summary = "获取容器日志") + public R> getContainerLog(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String containerName = params.get("containerName") != null ? params.get("containerName").toString() : ""; + String composePath = params.get("composePath") != null ? params.get("composePath").toString() : null; + + if (containerName.isEmpty() && (composePath == null || composePath.isEmpty())) { + return R.fail("containerName和composePath不能同时为空"); + } + + Map result = onePanelService.getContainerLog(serverId, containerName, composePath); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(result); + } + + + /** + * 更新运行时 + */ + @PostMapping("/update") + @Operation(summary = "更新运行时") + public R updateRuntime(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("id") == null) { + return R.fail("id不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + params.remove("serverId"); + + Map result = onePanelService.updateRuntime(serverId, params); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + + return R.ok(); + } + /** + * 获取Node脚本 + */ + @PostMapping("/node/scripts") + @Operation(summary = "获取Node脚本") + public R>> getNodeScripts(@RequestBody Map params) { + if (params.get("serverId") == null) { + return R.fail("serverId不能为空"); + } + if (params.get("codeDir") == null) { + return R.fail("codeDir不能为空"); + } + + Long serverId = Long.parseLong(params.get("serverId").toString()); + String codeDir = params.get("codeDir").toString(); + + List> result = onePanelService.getNodeScripts(serverId, codeDir); + return R.ok(result); + } +} + diff --git a/src/main/java/com/nanxiislet/admin/controller/SysApprovalController.java b/src/main/java/com/nanxiislet/admin/controller/SysApprovalController.java new file mode 100644 index 0000000..f1f98d1 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/SysApprovalController.java @@ -0,0 +1,153 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.dto.approval.ApprovalInstanceVO; +import com.nanxiislet.admin.dto.approval.ApprovalStats; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateRequest; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateVO; +import com.nanxiislet.admin.service.SysApprovalInstanceService; +import com.nanxiislet.admin.service.SysApprovalTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 审批流程管理控制器 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Slf4j +@RestController +@RequestMapping("/system/approval") +@Tag(name = "审批流程管理", description = "审批模板、实例管理") +public class SysApprovalController { + + @Resource + private SysApprovalTemplateService templateService; + + @Resource + private SysApprovalInstanceService instanceService; + + // ==================== 统计 ==================== + + @GetMapping("/stats") + @Operation(summary = "获取审批统计数据") + public R getStats() { + return R.ok(templateService.getStats()); + } + + // ==================== 模板管理 ==================== + + @GetMapping("/template/page") + @Operation(summary = "分页查询模板列表") + public R> templatePage( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String scenario, + @RequestParam(required = false) Boolean enabled) { + return R.ok(templateService.listPage(page, pageSize, scenario, enabled)); + } + + @GetMapping("/template/list") + @Operation(summary = "获取所有模板列表") + public R> templateList( + @RequestParam(required = false) String scenario) { + return R.ok(templateService.listAll(scenario)); + } + + @GetMapping("/template/{id}") + @Operation(summary = "获取模板详情") + public R getTemplate(@PathVariable Long id) { + return R.ok(templateService.getDetail(id)); + } + + @PostMapping("/template") + @Operation(summary = "创建模板") + public R createTemplate(@RequestBody ApprovalTemplateRequest request) { + return R.ok(templateService.create(request)); + } + + @PutMapping("/template/{id}") + @Operation(summary = "更新模板") + public R updateTemplate(@PathVariable Long id, @RequestBody ApprovalTemplateRequest request) { + request.setId(id); + templateService.update(request); + return R.ok(); + } + + @DeleteMapping("/template/{id}") + @Operation(summary = "删除模板") + public R deleteTemplate(@PathVariable Long id) { + templateService.delete(id); + return R.ok(); + } + + @PostMapping("/template/{id}/toggle") + @Operation(summary = "切换模板启用状态") + public R toggleTemplate(@PathVariable Long id) { + templateService.toggle(id); + return R.ok(); + } + + // ==================== 实例管理 ==================== + + @GetMapping("/instance/page") + @Operation(summary = "分页查询实例列表") + public R> instancePage( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String scenario, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return R.ok(instanceService.listPage(page, pageSize, scenario, status, keyword)); + } + + @GetMapping("/instance/{id}") + @Operation(summary = "获取实例详情") + public R getInstance(@PathVariable Long id) { + return R.ok(instanceService.getDetail(id)); + } + + @PostMapping("/instance/{id}/submit") + @Operation(summary = "提交审批") + public R submitInstance(@PathVariable Long id) { + instanceService.submit(id); + return R.ok(); + } + + @PostMapping("/instance/{id}/approve") + @Operation(summary = "审批操作") + public R approveInstance(@PathVariable Long id, @RequestBody ApproveRequest request) { + instanceService.approve(id, request.getNodeId(), request.getApproverId(), request.getAction(), request.getComment()); + return R.ok(); + } + + @PostMapping("/instance/{id}/withdraw") + @Operation(summary = "撤回审批") + public R withdrawInstance(@PathVariable Long id) { + instanceService.withdraw(id); + return R.ok(); + } + + @PostMapping("/instance/{id}/cancel") + @Operation(summary = "取消审批") + public R cancelInstance(@PathVariable Long id) { + instanceService.cancel(id); + return R.ok(); + } + + @Data + public static class ApproveRequest { + private Long nodeId; + private Long approverId; + private String action; // approve/reject + private String comment; + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/SysDeptController.java b/src/main/java/com/nanxiislet/admin/controller/SysDeptController.java new file mode 100644 index 0000000..d7aef96 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/SysDeptController.java @@ -0,0 +1,73 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.entity.SysDept; +import com.nanxiislet.admin.service.SysDeptService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 部门管理控制器 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Slf4j +@RestController +@RequestMapping("/system/dept") +@Tag(name = "部门管理", description = "部门树形结构管理") +public class SysDeptController { + + @Resource + private SysDeptService deptService; + + @GetMapping("/tree") + @Operation(summary = "获取部门树") + public R> tree() { + return R.ok(deptService.listTree()); + } + + @GetMapping("/list") + @Operation(summary = "获取部门列表(扁平结构)") + public R> list() { + return R.ok(deptService.listAll()); + } + + @GetMapping("/{id}") + @Operation(summary = "获取部门详情") + public R getById(@PathVariable Long id) { + return R.ok(deptService.getById(id)); + } + + @PostMapping + @Operation(summary = "创建部门") + public R create(@RequestBody SysDept dept) { + return R.ok(deptService.create(dept)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新部门") + public R update(@PathVariable Long id, @RequestBody SysDept dept) { + dept.setId(id); + deptService.updateDept(dept); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除部门") + public R delete(@PathVariable Long id) { + deptService.deleteDept(id); + return R.ok(); + } + + @GetMapping("/{id}/users") + @Operation(summary = "获取部门用户列表") + public R> getDeptUsers(@PathVariable Long id) { + return R.ok(deptService.getDeptUserIds(id)); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/SysDictController.java b/src/main/java/com/nanxiislet/admin/controller/SysDictController.java new file mode 100644 index 0000000..4a1f231 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/SysDictController.java @@ -0,0 +1,126 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.entity.SysDict; +import com.nanxiislet.admin.entity.SysDictItem; +import com.nanxiislet.admin.service.SysDictItemService; +import com.nanxiislet.admin.service.SysDictService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 字典管理控制器 + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Slf4j +@RestController +@RequestMapping("/system/dict") +@Tag(name = "字典管理", description = "字典类型和字典项CRUD") +public class SysDictController { + + @Resource + private SysDictService dictService; + + @Resource + private SysDictItemService dictItemService; + + // ==================== 字典类型 ==================== + + @GetMapping("/page") + @Operation(summary = "分页查询字典") + public R> page( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String name, + @RequestParam(required = false) String code) { + return R.ok(dictService.page(page, pageSize, name, code)); + } + + @GetMapping("/list") + @Operation(summary = "获取所有字典") + public R> list() { + return R.ok(dictService.list()); + } + + @GetMapping("/{id}") + @Operation(summary = "获取字典详情") + public R getById(@PathVariable Long id) { + return R.ok(dictService.getById(id)); + } + + @GetMapping("/code/{code}") + @Operation(summary = "根据编码获取字典") + public R getByCode(@PathVariable String code) { + return R.ok(dictService.getByCode(code)); + } + + @PostMapping + @Operation(summary = "创建字典") + public R create(@RequestBody SysDict dict) { + return R.ok(dictService.create(dict)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新字典") + public R update(@PathVariable Long id, @RequestBody SysDict dict) { + dict.setId(id); + dictService.updateDict(dict); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除字典") + public R delete(@PathVariable Long id) { + dictService.deleteDict(id); + return R.ok(); + } + + // ==================== 字典项 ==================== + + @GetMapping("/{dictId}/items") + @Operation(summary = "获取字典项列表") + public R> listItems(@PathVariable Long dictId) { + return R.ok(dictItemService.listByDictId(dictId)); + } + + @GetMapping("/code/{code}/items") + @Operation(summary = "根据字典编码获取字典项") + public R> listItemsByCode(@PathVariable String code) { + return R.ok(dictItemService.listByDictCode(code)); + } + + @GetMapping("/item/{id}") + @Operation(summary = "获取字典项详情") + public R getItemById(@PathVariable Long id) { + return R.ok(dictItemService.getById(id)); + } + + @PostMapping("/item") + @Operation(summary = "创建字典项") + public R createItem(@RequestBody SysDictItem item) { + return R.ok(dictItemService.create(item)); + } + + @PutMapping("/item/{id}") + @Operation(summary = "更新字典项") + public R updateItem(@PathVariable Long id, @RequestBody SysDictItem item) { + item.setId(id); + dictItemService.updateItem(item); + return R.ok(); + } + + @DeleteMapping("/item/{id}") + @Operation(summary = "删除字典项") + public R deleteItem(@PathVariable Long id) { + dictItemService.deleteItem(id); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/SysMenuController.java b/src/main/java/com/nanxiislet/admin/controller/SysMenuController.java new file mode 100644 index 0000000..b9c4215 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/SysMenuController.java @@ -0,0 +1,85 @@ +package com.nanxiislet.admin.controller; + +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.entity.SysMenu; +import com.nanxiislet.admin.service.SysMenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 菜单管理控制器 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Slf4j +@RestController +@RequestMapping("/system/menu") +@Tag(name = "菜单管理", description = "菜单CRUD等") +public class SysMenuController { + + @Resource + private SysMenuService menuService; + + @Resource + private com.nanxiislet.admin.service.PlatformProjectService projectService; + + @GetMapping("/tree") + @Operation(summary = "获取菜单树") + public R> tree() { + return R.ok(menuService.listTree()); + } + + @GetMapping("/list") + @Operation(summary = "获取所有菜单,支持按项目ID筛选") + public R> list(@RequestParam(required = false) Long projectId) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + + if (projectId != null) { + // 特殊逻辑:如果是主框架项目(NanxiAdmin),包含 projectId 为空的记录 + // 为此我们需要先查项目信息 + com.nanxiislet.admin.entity.PlatformProject project = projectService.getById(projectId); + if (project != null && ("NanxiAdmin".equalsIgnoreCase(project.getShortName()) || "NanxiAdmin".equals(project.getCode()))) { + wrapper.and(w -> w.eq(SysMenu::getProjectId, projectId).or().isNull(SysMenu::getProjectId)); + } else { + wrapper.eq(SysMenu::getProjectId, projectId); + } + } + + wrapper.orderByAsc(SysMenu::getParentId).orderByAsc(SysMenu::getSort); + return R.ok(menuService.list(wrapper)); + } + + @GetMapping("/{id}") + @Operation(summary = "获取菜单详情") + public R getById(@PathVariable Long id) { + return R.ok(menuService.getById(id)); + } + + @PostMapping + @Operation(summary = "创建菜单") + public R create(@RequestBody SysMenu menu) { + return R.ok(menuService.create(menu)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新菜单") + public R update(@PathVariable Long id, @RequestBody SysMenu menu) { + menu.setId(id); + menuService.updateMenu(menu); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除菜单") + public R delete(@PathVariable Long id) { + menuService.deleteMenu(id); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/SysRoleController.java b/src/main/java/com/nanxiislet/admin/controller/SysRoleController.java new file mode 100644 index 0000000..73c448e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/SysRoleController.java @@ -0,0 +1,99 @@ +package com.nanxiislet.admin.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.entity.SysRole; +import com.nanxiislet.admin.service.SysRoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 角色管理控制器 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Slf4j +@RestController +@RequestMapping("/system/role") +@Tag(name = "角色管理", description = "角色CRUD、权限分配等") +public class SysRoleController { + + @Resource + private SysRoleService roleService; + + @GetMapping("/page") + @Operation(summary = "分页查询角色") + public R> page( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String keyword) { + return R.ok(roleService.listPage(page, pageSize, keyword)); + } + + @GetMapping("/list") + @Operation(summary = "获取所有有效角色") + public R> list() { + return R.ok(roleService.listAll()); + } + + @GetMapping("/list-all") + @Operation(summary = "获取所有角色选项(简化版)") + public R>> listAll() { + List roles = roleService.listAll(); + List> options = roles.stream() + .map(role -> { + java.util.Map map = new java.util.HashMap<>(); + map.put("code", role.getCode()); + map.put("name", role.getName()); + return map; + }) + .toList(); + return R.ok(options); + } + + @GetMapping("/{id}") + @Operation(summary = "获取角色详情") + public R getById(@PathVariable Long id) { + return R.ok(roleService.getById(id)); + } + + @PostMapping + @Operation(summary = "创建角色") + public R create(@RequestBody SysRole role) { + return R.ok(roleService.create(role)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新角色") + public R update(@PathVariable Long id, @RequestBody SysRole role) { + role.setId(id); + roleService.updateRole(role); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除角色") + public R delete(@PathVariable Long id) { + roleService.deleteRole(id); + return R.ok(); + } + + @GetMapping("/{id}/menus") + @Operation(summary = "获取角色的菜单ID列表") + public R> getRoleMenus(@PathVariable Long id) { + return R.ok(roleService.getRoleMenuIds(id)); + } + + @PostMapping("/{id}/menus") + @Operation(summary = "分配菜单权限") + public R assignMenus(@PathVariable Long id, @RequestBody List menuIds) { + roleService.assignMenus(id, menuIds); + return R.ok(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/UploadController.java b/src/main/java/com/nanxiislet/admin/controller/UploadController.java new file mode 100644 index 0000000..3be839c --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/UploadController.java @@ -0,0 +1,89 @@ +package com.nanxiislet.admin.controller; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * 文件上传控制器 + */ +@Slf4j +@RestController +@RequestMapping("/upload") +@Tag(name = "文件上传", description = "文件上传相关接口") +public class UploadController { + + @Value("${nanxiislet.upload.path:./uploads}") + private String uploadPath; + + @Value("${nanxiislet.upload.max-size:104857600}") + private long maxSize; + + @PostMapping + @Operation(summary = "上传文件") + public R> upload(@RequestParam("file") MultipartFile file) { + if (file.isEmpty()) { + throw new BusinessException("请选择文件"); + } + + if (file.getSize() > maxSize) { + throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED); + } + + try { + String originalFilename = file.getOriginalFilename(); + String extension = FileUtil.extName(originalFilename); + String newFilename = IdUtil.fastSimpleUUID() + "." + extension; + + // 按日期分目录 + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String relativePath = dateDir + "/" + newFilename; + String fullPath = uploadPath + "/" + relativePath; + + File destFile = new File(fullPath); + FileUtil.mkParentDirs(destFile); + file.transferTo(destFile); + + log.info("文件上传成功: {} -> {}", originalFilename, fullPath); + + Map result = new HashMap<>(); + result.put("url", "/uploads/" + relativePath); + result.put("filename", originalFilename); + result.put("size", String.valueOf(file.getSize())); + + return R.ok(result); + } catch (Exception e) { + log.error("文件上传失败", e); + throw new BusinessException(ResultCode.FILE_UPLOAD_ERROR); + } + } + + @PostMapping("/image") + @Operation(summary = "上传图片") + public R> uploadImage(@RequestParam("file") MultipartFile file) { + if (file.isEmpty()) { + throw new BusinessException("请选择图片"); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BusinessException(ResultCode.FILE_TYPE_NOT_ALLOWED, "只能上传图片文件"); + } + + return upload(file); + } +} diff --git a/src/main/java/com/nanxiislet/admin/controller/UserController.java b/src/main/java/com/nanxiislet/admin/controller/UserController.java new file mode 100644 index 0000000..43e43e3 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/controller/UserController.java @@ -0,0 +1,116 @@ +package com.nanxiislet.admin.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.PageResult; +import com.nanxiislet.admin.common.result.R; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.system.UserQuery; +import com.nanxiislet.admin.entity.SysUser; +import com.nanxiislet.admin.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.Data; +import org.springframework.web.bind.annotation.*; + +/** + * 用户管理控制器 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@RestController +@RequestMapping("/system/user") +@Tag(name = "用户管理", description = "系统用户的增删改查") +public class UserController { + + @Resource + private UserService userService; + + @GetMapping("/list") + @Operation(summary = "用户列表", description = "分页查询用户列表") + @SaCheckRole("admin") + public R> list(UserQuery query) { + Page page = userService.listPage(query); + return R.ok(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize())); + } + + @GetMapping("/{id}") + @Operation(summary = "用户详情", description = "根据ID获取用户详情") + @SaCheckRole("admin") + public R getById(@PathVariable Long id) { + SysUser user = userService.getById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + user.setPassword(null); + return R.ok(user); + } + + @PostMapping + @Operation(summary = "新增用户", description = "创建新用户") + @SaCheckRole("admin") + public R create(@Valid @RequestBody SysUser user) { + userService.createUser(user); + return R.ok(); + } + + @PutMapping("/{id}") + @Operation(summary = "更新用户", description = "更新用户信息") + @SaCheckRole("admin") + public R update(@PathVariable Long id, @Valid @RequestBody SysUser user) { + user.setId(id); + userService.updateUser(user); + return R.ok(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除用户", description = "删除用户") + @SaCheckRole("admin") + public R delete(@PathVariable Long id) { + userService.removeById(id); + return R.ok(); + } + + @PostMapping("/{id}/reset-password") + @Operation(summary = "重置密码", description = "管理员重置用户密码") + @SaCheckRole("admin") + public R resetPassword(@PathVariable Long id, @RequestBody ResetPasswordRequest request) { + userService.resetPassword(id, request.getNewPassword()); + return R.ok(); + } + + @PostMapping("/change-password") + @Operation(summary = "修改密码", description = "用户修改自己的密码") + public R changePassword(@RequestBody ChangePasswordRequest request) { + cn.dev33.satoken.stp.StpUtil.checkLogin(); + Long userId = Long.parseLong(cn.dev33.satoken.stp.StpUtil.getLoginId().toString()); + userService.changePassword(userId, request.getOldPassword(), request.getNewPassword()); + return R.ok(); + } + + @PutMapping("/{id}/status") + @Operation(summary = "修改状态", description = "启用/禁用用户") + @SaCheckRole("admin") + public R updateStatus(@PathVariable Long id, @RequestParam Integer status) { + SysUser user = new SysUser(); + user.setId(id); + user.setStatus(status); + userService.updateById(user); + return R.ok(); + } + + @Data + public static class ResetPasswordRequest { + private String newPassword; + } + + @Data + public static class ChangePasswordRequest { + private String oldPassword; + private String newPassword; + } +} diff --git a/src/main/java/com/nanxiislet/admin/dto/CertificateApplyRequest.java b/src/main/java/com/nanxiislet/admin/dto/CertificateApplyRequest.java new file mode 100644 index 0000000..6b0f849 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/CertificateApplyRequest.java @@ -0,0 +1,46 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 证书申请请求DTO + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@Schema(description = "证书申请请求") +public class CertificateApplyRequest { + + @NotNull(message = "服务器ID不能为空") + @Schema(description = "服务器ID") + private Long serverId; + + @NotBlank(message = "主域名不能为空") + @Schema(description = "主域名") + private String primaryDomain; + + @Schema(description = "其他域名(逗号分隔)") + private String otherDomains; + + @NotNull(message = "Acme账户ID不能为空") + @Schema(description = "Acme账户ID") + private Long acmeAccountId; + + @NotNull(message = "DNS账户ID不能为空") + @Schema(description = "DNS账户ID") + private Long dnsAccountId; + + @Schema(description = "密钥算法 P256/P384/RSA2048/RSA4096") + private String keyType = "P256"; + + @Schema(description = "自动续签") + private Boolean autoRenew = true; + + @Schema(description = "备注") + private String description; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/CertificateApplyResult.java b/src/main/java/com/nanxiislet/admin/dto/CertificateApplyResult.java new file mode 100644 index 0000000..905db30 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/CertificateApplyResult.java @@ -0,0 +1,80 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 证书申请结果DTO + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@Schema(description = "证书申请结果") +public class CertificateApplyResult { + + @Schema(description = "是否成功") + private Boolean success; + + @Schema(description = "结果消息") + private String message; + + @Schema(description = "1Panel证书ID") + private Long panelSslId; + + @Schema(description = "本地证书记录ID") + private Long certificateId; + + @Schema(description = "申请步骤") + private List steps = new ArrayList<>(); + + @Data + @Schema(description = "申请步骤") + public static class ApplyStep { + @Schema(description = "步骤名称") + private String step; + + @Schema(description = "状态 success/failed/skipped") + private String status; + + @Schema(description = "消息") + private String message; + + public ApplyStep() {} + + public ApplyStep(String step, String status, String message) { + this.step = step; + this.status = status; + this.message = message; + } + } + + public static CertificateApplyResult success(String message) { + CertificateApplyResult result = new CertificateApplyResult(); + result.setSuccess(true); + result.setMessage(message); + return result; + } + + public static CertificateApplyResult failed(String message) { + CertificateApplyResult result = new CertificateApplyResult(); + result.setSuccess(false); + result.setMessage(message); + return result; + } + + public void addSuccessStep(String step, String message) { + this.steps.add(new ApplyStep(step, "success", message)); + } + + public void addFailedStep(String step, String message) { + this.steps.add(new ApplyStep(step, "failed", message)); + } + + public void addSkippedStep(String step, String message) { + this.steps.add(new ApplyStep(step, "skipped", message)); + } +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DeployRequest.java b/src/main/java/com/nanxiislet/admin/dto/DeployRequest.java new file mode 100644 index 0000000..e2ec7c3 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DeployRequest.java @@ -0,0 +1,30 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 部署请求DTO + * + * @author NanxiIslet + * @since 2024-01-10 + */ +@Data +@Schema(description = "部署请求") +public class DeployRequest { + + @Schema(description = "项目ID", required = true) + private Long projectId; + + @Schema(description = "是否启用HTTPS") + private Boolean enableHttps = true; + + @Schema(description = "Acme账户ID(启用HTTPS时需要)") + private Long acmeAccountId; + + @Schema(description = "DNS账户ID(启用HTTPS时需要)") + private Long dnsAccountId; + + @Schema(description = "如果网站不存在是否自动创建") + private Boolean createIfNotExist = true; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DeployResult.java b/src/main/java/com/nanxiislet/admin/dto/DeployResult.java new file mode 100644 index 0000000..2c2e9b1 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DeployResult.java @@ -0,0 +1,114 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 部署结果DTO + * + * @author NanxiIslet + * @since 2024-01-10 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "部署结果") +public class DeployResult { + + @Schema(description = "是否成功") + private Boolean success; + + @Schema(description = "结果消息") + private String message; + + @Schema(description = "1Panel网站ID") + private Long websiteId; + + @Schema(description = "1Panel证书ID") + private Long sslCertificateId; + + @Schema(description = "部署步骤详情") + @Builder.Default + private List steps = new ArrayList<>(); + + /** + * 部署步骤 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DeployStep { + + @Schema(description = "步骤名称") + private String step; + + @Schema(description = "步骤状态 success/failed/skipped") + private String status; + + @Schema(description = "步骤消息") + private String message; + } + + /** + * 添加成功步骤 + */ + public void addSuccessStep(String stepName, String message) { + this.steps.add(DeployStep.builder() + .step(stepName) + .status("success") + .message(message) + .build()); + } + + /** + * 添加失败步骤 + */ + public void addFailedStep(String stepName, String message) { + this.steps.add(DeployStep.builder() + .step(stepName) + .status("failed") + .message(message) + .build()); + } + + /** + * 添加跳过步骤 + */ + public void addSkippedStep(String stepName, String message) { + this.steps.add(DeployStep.builder() + .step(stepName) + .status("skipped") + .message(message) + .build()); + } + + /** + * 创建成功结果 + */ + public static DeployResult success(String message) { + return DeployResult.builder() + .success(true) + .message(message) + .steps(new ArrayList<>()) + .build(); + } + + /** + * 创建失败结果 + */ + public static DeployResult failed(String message) { + return DeployResult.builder() + .success(false) + .message(message) + .steps(new ArrayList<>()) + .build(); + } +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DomainDeployRequest.java b/src/main/java/com/nanxiislet/admin/dto/DomainDeployRequest.java new file mode 100644 index 0000000..2a0232e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DomainDeployRequest.java @@ -0,0 +1,33 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotNull; + +/** + * 域名部署请求DTO + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@Schema(description = "域名部署请求") +public class DomainDeployRequest { + + @NotNull(message = "域名ID不能为空") + @Schema(description = "域名ID") + private Long domainId; + + @Schema(description = "是否启用HTTPS") + private Boolean enableHttps; + + @Schema(description = "Acme账户ID(申请SSL证书时需要)") + private Long acmeAccountId; + + @Schema(description = "DNS账户ID(申请SSL证书时需要)") + private Long dnsAccountId; + + @Schema(description = "如果网站不存在是否自动创建") + private Boolean createIfNotExist = true; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DomainDeployResult.java b/src/main/java/com/nanxiislet/admin/dto/DomainDeployResult.java new file mode 100644 index 0000000..b15f05a --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DomainDeployResult.java @@ -0,0 +1,80 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 域名部署结果DTO + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@Schema(description = "域名部署结果") +public class DomainDeployResult { + + @Schema(description = "是否成功") + private Boolean success; + + @Schema(description = "结果消息") + private String message; + + @Schema(description = "1Panel网站ID") + private Long websiteId; + + @Schema(description = "1Panel证书ID") + private Long sslCertificateId; + + @Schema(description = "部署步骤列表") + private List steps = new ArrayList<>(); + + @Data + @Schema(description = "部署步骤") + public static class DeployStep { + @Schema(description = "步骤名称") + private String step; + + @Schema(description = "状态 success/failed/skipped") + private String status; + + @Schema(description = "消息") + private String message; + + public DeployStep() {} + + public DeployStep(String step, String status, String message) { + this.step = step; + this.status = status; + this.message = message; + } + } + + public static DomainDeployResult success(String message) { + DomainDeployResult result = new DomainDeployResult(); + result.setSuccess(true); + result.setMessage(message); + return result; + } + + public static DomainDeployResult failed(String message) { + DomainDeployResult result = new DomainDeployResult(); + result.setSuccess(false); + result.setMessage(message); + return result; + } + + public void addSuccessStep(String step, String message) { + this.steps.add(new DeployStep(step, "success", message)); + } + + public void addFailedStep(String step, String message) { + this.steps.add(new DeployStep(step, "failed", message)); + } + + public void addSkippedStep(String step, String message) { + this.steps.add(new DeployStep(step, "skipped", message)); + } +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DomainQueryDTO.java b/src/main/java/com/nanxiislet/admin/dto/DomainQueryDTO.java new file mode 100644 index 0000000..871dfe3 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DomainQueryDTO.java @@ -0,0 +1,26 @@ +package com.nanxiislet.admin.dto; + +import com.nanxiislet.admin.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 域名查询参数 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class DomainQueryDTO extends BasePageQuery { + + @Schema(description = "服务器ID") + private Long serverId; + + @Schema(description = "域名状态") + private String status; + + @Schema(description = "SSL状态") + private String sslStatus; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/DomainStatsDTO.java b/src/main/java/com/nanxiislet/admin/dto/DomainStatsDTO.java new file mode 100644 index 0000000..fa32233 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/DomainStatsDTO.java @@ -0,0 +1,30 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 域名统计DTO + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@Schema(description = "域名统计信息") +public class DomainStatsDTO { + + @Schema(description = "总计") + private Long total; + + @Schema(description = "正常数量") + private Long active; + + @Schema(description = "待配置数量") + private Long pending; + + @Schema(description = "SSL即将过期数量") + private Long sslExpiring; + + @Schema(description = "已部署数量") + private Long deployed; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/ServerInfoDto.java b/src/main/java/com/nanxiislet/admin/dto/ServerInfoDto.java new file mode 100644 index 0000000..a134666 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/ServerInfoDto.java @@ -0,0 +1,130 @@ +package com.nanxiislet.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 服务器信息DTO(包含实时资源使用情况) + * + * @author NanxiIslet + * @since 2024-01-10 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "服务器信息DTO") +public class ServerInfoDto { + + @Schema(description = "服务器ID") + private Long id; + + @Schema(description = "服务器名称") + private String name; + + @Schema(description = "公网IP") + private String ip; + + @Schema(description = "内网IP") + private String internalIp; + + @Schema(description = "SSH端口") + private Integer port; + + @Schema(description = "服务器类型 physical/virtual/cloud") + private String type; + + @Schema(description = "状态 online/offline/warning/maintenance") + private String status; + + @Schema(description = "操作系统") + private String os; + + @Schema(description = "标签") + private List tags; + + + + @Schema(description = "1Panel面板地址") + private String panelUrl; + + @Schema(description = "1Panel面板端口") + private Integer panelPort; + + @Schema(description = "描述") + private String description; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + // ==================== 资源使用情况(从1Panel获取) ==================== + + @Schema(description = "CPU信息") + private CpuInfo cpu; + + @Schema(description = "内存信息") + private MemoryInfo memory; + + @Schema(description = "磁盘信息") + private DiskInfo disk; + + /** + * CPU信息 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CpuInfo { + @Schema(description = "CPU核心数") + private Integer cores; + + @Schema(description = "CPU使用率(%)") + private Double usage; + } + + /** + * 内存信息 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MemoryInfo { + @Schema(description = "总内存(GB)") + private Double total; + + @Schema(description = "已用内存(GB)") + private Double used; + + @Schema(description = "内存使用率(%)") + private Double usage; + } + + /** + * 磁盘信息 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DiskInfo { + @Schema(description = "总磁盘(GB)") + private Double total; + + @Schema(description = "已用磁盘(GB)") + private Double used; + + @Schema(description = "磁盘使用率(%)") + private Double usage; + } +} diff --git a/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalInstanceVO.java b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalInstanceVO.java new file mode 100644 index 0000000..e2da3e2 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalInstanceVO.java @@ -0,0 +1,24 @@ +package com.nanxiislet.admin.dto.approval; + +import com.nanxiislet.admin.entity.SysApprovalInstance; +import com.nanxiislet.admin.entity.SysApprovalRecord; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 审批实例VO(包含审批记录) + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalInstanceVO extends SysApprovalInstance { + + /** + * 审批记录列表 + */ + private List records; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalStats.java b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalStats.java new file mode 100644 index 0000000..46cc0ea --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalStats.java @@ -0,0 +1,48 @@ +package com.nanxiislet.admin.dto.approval; + +import lombok.Data; + +/** + * 审批统计数据 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +public class ApprovalStats { + + /** + * 模板总数 + */ + private Long totalTemplates; + + /** + * 已启用模板数 + */ + private Long enabledTemplates; + + /** + * 实例总数 + */ + private Long totalInstances; + + /** + * 待处理实例数 + */ + private Long pendingInstances; + + /** + * 审批中实例数 + */ + private Long inProgressInstances; + + /** + * 已通过实例数 + */ + private Long approvedInstances; + + /** + * 已拒绝实例数 + */ + private Long rejectedInstances; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateRequest.java b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateRequest.java new file mode 100644 index 0000000..d3829df --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateRequest.java @@ -0,0 +1,46 @@ +package com.nanxiislet.admin.dto.approval; + +import com.nanxiislet.admin.entity.SysApprovalNode; +import lombok.Data; + +import java.util.List; + +/** + * 创建/更新审批模板请求 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +public class ApprovalTemplateRequest { + + /** + * 模板ID(更新时需要) + */ + private Long id; + + /** + * 模板名称 + */ + private String name; + + /** + * 描述 + */ + private String description; + + /** + * 适用场景 + */ + private String scenario; + + /** + * 是否启用 + */ + private Boolean enabled; + + /** + * 审批节点列表 + */ + private List nodes; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateVO.java b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateVO.java new file mode 100644 index 0000000..965fd81 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/approval/ApprovalTemplateVO.java @@ -0,0 +1,24 @@ +package com.nanxiislet.admin.dto.approval; + +import com.nanxiislet.admin.entity.SysApprovalNode; +import com.nanxiislet.admin.entity.SysApprovalTemplate; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 审批模板VO(包含节点信息) + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalTemplateVO extends SysApprovalTemplate { + + /** + * 审批节点列表 + */ + private List nodes; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/auth/CaptchaResponse.java b/src/main/java/com/nanxiislet/admin/dto/auth/CaptchaResponse.java new file mode 100644 index 0000000..e1bc3ae --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/auth/CaptchaResponse.java @@ -0,0 +1,27 @@ +package com.nanxiislet.admin.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 验证码响应DTO + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "验证码响应") +public class CaptchaResponse { + + @Schema(description = "验证码Key") + private String captchaKey; + + @Schema(description = "验证码图片(Base64)") + private String captchaImage; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/auth/LoginRequest.java b/src/main/java/com/nanxiislet/admin/dto/auth/LoginRequest.java new file mode 100644 index 0000000..4f1b4ec --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/auth/LoginRequest.java @@ -0,0 +1,32 @@ +package com.nanxiislet.admin.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 登录请求DTO + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Schema(description = "登录请求") +public class LoginRequest { + + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "密码不能为空") + private String password; + + @Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "验证码不能为空") + private String captcha; + + @Schema(description = "验证码Key", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "验证码Key不能为空") + private String captchaKey; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/auth/LoginResponse.java b/src/main/java/com/nanxiislet/admin/dto/auth/LoginResponse.java new file mode 100644 index 0000000..7aadfd7 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/auth/LoginResponse.java @@ -0,0 +1,33 @@ +package com.nanxiislet.admin.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 登录响应DTO + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "登录响应") +public class LoginResponse { + + @Schema(description = "访问令牌") + private String token; + + @Schema(description = "刷新令牌") + private String refreshToken; + + @Schema(description = "过期时间(秒)") + private long expires; + + @Schema(description = "用户信息") + private UserInfoVO userInfo; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/auth/UserInfoVO.java b/src/main/java/com/nanxiislet/admin/dto/auth/UserInfoVO.java new file mode 100644 index 0000000..f952c09 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/auth/UserInfoVO.java @@ -0,0 +1,56 @@ +package com.nanxiislet.admin.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 用户信息VO + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "用户信息") +public class UserInfoVO { + + @Schema(description = "用户ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "角色") + private String role; + + @Schema(description = "权限列表") + private List permissions; + + @Schema(description = "菜单列表") + private List menus; + + @Schema(description = "创建时间") + private String createTime; + + @Schema(description = "最后登录时间") + private String lastLoginTime; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/query/ExpenseQuery.java b/src/main/java/com/nanxiislet/admin/dto/query/ExpenseQuery.java new file mode 100644 index 0000000..e534421 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/query/ExpenseQuery.java @@ -0,0 +1,36 @@ +package com.nanxiislet.admin.dto.query; + +import com.nanxiislet.admin.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 支出查询条件 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "支出查询条件") +public class ExpenseQuery extends BasePageQuery { + + @Schema(description = "支出类型") + private String type; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "部门ID") + private Long departmentId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "开始日期") + private String startDate; + + @Schema(description = "结束日期") + private String endDate; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/query/IncomeQuery.java b/src/main/java/com/nanxiislet/admin/dto/query/IncomeQuery.java new file mode 100644 index 0000000..55a11a2 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/query/IncomeQuery.java @@ -0,0 +1,36 @@ +package com.nanxiislet.admin.dto.query; + +import com.nanxiislet.admin.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 收入查询条件 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "收入查询条件") +public class IncomeQuery extends BasePageQuery { + + @Schema(description = "收入类型") + private String type; + + @Schema(description = "客户ID") + private Long customerId; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "开始日期") + private String startDate; + + @Schema(description = "结束日期") + private String endDate; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/query/ProjectQuery.java b/src/main/java/com/nanxiislet/admin/dto/query/ProjectQuery.java new file mode 100644 index 0000000..7e9bce3 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/query/ProjectQuery.java @@ -0,0 +1,27 @@ +package com.nanxiislet.admin.dto.query; + +import com.nanxiislet.admin.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 项目查询条件 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "项目查询条件") +public class ProjectQuery extends BasePageQuery { + + @Schema(description = "项目类型") + private String type; + + @Schema(description = "服务器ID") + private Long serverId; + + @Schema(description = "状态") + private String status; +} diff --git a/src/main/java/com/nanxiislet/admin/dto/system/UserQuery.java b/src/main/java/com/nanxiislet/admin/dto/system/UserQuery.java new file mode 100644 index 0000000..a256469 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/dto/system/UserQuery.java @@ -0,0 +1,26 @@ +package com.nanxiislet.admin.dto.system; + +import com.nanxiislet.admin.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户查询参数 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class UserQuery extends BasePageQuery { + + @Schema(description = "角色编码") + private String role; + + @Schema(description = "部门ID") + private Long deptId; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceAccount.java b/src/main/java/com/nanxiislet/admin/entity/FinanceAccount.java new file mode 100644 index 0000000..22cafa8 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceAccount.java @@ -0,0 +1,58 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * 财务账户实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("finance_account") +@Schema(description = "财务账户") +public class FinanceAccount extends BaseEntity { + + @Schema(description = "账户名称") + private String name; + + @Schema(description = "账户类型 corporate-对公账户 merchant-商户号") + private String type; + + @Schema(description = "开户银行") + private String bankName; + + @Schema(description = "开户支行") + private String bankBranch; + + @Schema(description = "银行账号") + private String accountNo; + + @Schema(description = "商户ID") + private String merchantId; + + @Schema(description = "商户平台 wechat/alipay/unionpay/other") + private String merchantPlatform; + + @Schema(description = "AppID") + private String appId; + + @Schema(description = "当前余额") + private BigDecimal balance; + + @Schema(description = "状态 active-正常 inactive-禁用") + private String status; + + @Schema(description = "是否默认账户") + private Boolean isDefault; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceBudget.java b/src/main/java/com/nanxiislet/admin/entity/FinanceBudget.java new file mode 100644 index 0000000..68fad0e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceBudget.java @@ -0,0 +1,88 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 预算记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "finance_budget", autoResultMap = true) +@Schema(description = "预算记录") +public class FinanceBudget extends BaseEntity { + + @Schema(description = "预算名称") + private String name; + + @Schema(description = "预算周期 monthly/quarterly/yearly") + private String period; + + @Schema(description = "年份") + private Integer year; + + @Schema(description = "季度 1-4") + private Integer quarter; + + @Schema(description = "月份 1-12") + private Integer month; + + @Schema(description = "部门ID") + private Long departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "预算明细(JSON格式)") + @TableField(typeHandler = JacksonTypeHandler.class) + private List items; + + @Schema(description = "总预算") + private BigDecimal totalBudget; + + @Schema(description = "已使用金额") + private BigDecimal usedAmount; + + @Schema(description = "剩余金额") + private BigDecimal remainingAmount; + + @Schema(description = "使用率 0-100") + private BigDecimal usageRate; + + @Schema(description = "状态 draft/active/completed/cancelled") + private String status; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建人名称") + private String createdByName; + + /** + * 预算明细项 + */ + @Data + public static class BudgetItem { + private String expenseType; + private BigDecimal budgetAmount; + private BigDecimal usedAmount; + private BigDecimal remainingAmount; + } +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceExpense.java b/src/main/java/com/nanxiislet/admin/entity/FinanceExpense.java new file mode 100644 index 0000000..80bb36a --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceExpense.java @@ -0,0 +1,83 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 支出记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("finance_expense") +@Schema(description = "支出记录") +public class FinanceExpense extends BaseEntity { + + @Schema(description = "支出编号") + private String expenseNo; + + @Schema(description = "支出类型 salary/office/rent/travel/marketing/equipment/service/tax/social_insurance/other") + private String type; + + @Schema(description = "支出名称") + private String title; + + @Schema(description = "收款方名称") + private String payeeName; + + @Schema(description = "收款方账号") + private String payeeAccount; + + @Schema(description = "收款方开户行") + private String payeeBankName; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "关联项目ID") + private Long projectId; + + @Schema(description = "关联项目名称") + private String projectName; + + @Schema(description = "部门ID") + private Long departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "付款账户ID") + private Long accountId; + + @Schema(description = "付款账户名称") + private String accountName; + + @Schema(description = "状态 draft/pending/approved/paid/rejected") + private String status; + + @Schema(description = "审批流程ID") + private Long approvalId; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "附件路径(JSON格式)") + private String attachments; + + @Schema(description = "付款日期") + private LocalDate paymentDate; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建人名称") + private String createdByName; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceIncome.java b/src/main/java/com/nanxiislet/admin/entity/FinanceIncome.java new file mode 100644 index 0000000..c872fe4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceIncome.java @@ -0,0 +1,89 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 收入记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("finance_income") +@Schema(description = "收入记录") +public class FinanceIncome extends BaseEntity { + + @Schema(description = "收入编号") + private String incomeNo; + + @Schema(description = "收入类型 project/service_fee/consulting/commission/other") + private String type; + + @Schema(description = "收入名称") + private String title; + + @Schema(description = "客户ID") + private Long customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "客户联系方式") + private String customerContact; + + @Schema(description = "关联项目ID") + private Long projectId; + + @Schema(description = "关联项目名称") + private String projectName; + + @Schema(description = "合同编号") + private String contractNo; + + @Schema(description = "总金额") + private BigDecimal totalAmount; + + @Schema(description = "已收金额") + private BigDecimal receivedAmount; + + @Schema(description = "待收金额") + private BigDecimal pendingAmount; + + @Schema(description = "收款账户ID") + private Long accountId; + + @Schema(description = "收款账户名称") + private String accountName; + + @Schema(description = "状态 pending/partial/received/overdue") + private String status; + + @Schema(description = "预计收款日期") + private LocalDate expectedDate; + + @Schema(description = "实际收款日期") + private LocalDate actualDate; + + @Schema(description = "是否需要发票") + private Boolean invoiceRequired; + + @Schema(description = "发票是否已开") + private Boolean invoiceIssued; + + @Schema(description = "发票号") + private String invoiceNo; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建人名称") + private String createdByName; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceInvoice.java b/src/main/java/com/nanxiislet/admin/entity/FinanceInvoice.java new file mode 100644 index 0000000..9d2b7c5 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceInvoice.java @@ -0,0 +1,53 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 发票记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("finance_invoice") +@Schema(description = "发票记录") +public class FinanceInvoice extends BaseEntity { + + @Schema(description = "关联结算单ID") + private Long settlementId; + + @Schema(description = "发票类型 vat_special/vat_normal/personal") + private String type; + + @Schema(description = "发票抬头") + private String title; + + @Schema(description = "税号") + private String taxCode; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "电子发票文件URL") + private String fileUrl; + + @Schema(description = "状态 pending/issued/rejected") + private String status; + + @Schema(description = "提交时间") + private LocalDate submitTime; + + @Schema(description = "开票时间") + private LocalDate issueTime; + + @Schema(description = "驳回原因") + private String rejectReason; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceReimbursement.java b/src/main/java/com/nanxiislet/admin/entity/FinanceReimbursement.java new file mode 100644 index 0000000..d5e91e5 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceReimbursement.java @@ -0,0 +1,100 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 报销记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "finance_reimbursement", autoResultMap = true) +@Schema(description = "报销记录") +public class FinanceReimbursement extends BaseEntity { + + @Schema(description = "报销单号") + private String reimbursementNo; + + @Schema(description = "报销类型 travel/meal/transport/communication/office/other") + private String type; + + @Schema(description = "报销标题") + private String title; + + @Schema(description = "申请人ID") + private Long applicantId; + + @Schema(description = "申请人名称") + private String applicantName; + + @Schema(description = "部门ID") + private Long departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "总金额") + private BigDecimal totalAmount; + + @Schema(description = "报销明细(JSON格式)") + @TableField(typeHandler = JacksonTypeHandler.class) + private List items; + + @Schema(description = "状态 draft/pending/approved/paid/rejected") + private String status; + + @Schema(description = "审批流程ID") + private Long approvalId; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "当前审批人") + private String currentApprover; + + @Schema(description = "收款人银行户名") + private String bankAccountName; + + @Schema(description = "收款人银行账号") + private String bankAccountNo; + + @Schema(description = "收款人开户行") + private String bankName; + + @Schema(description = "付款账户ID") + private Long paymentAccountId; + + @Schema(description = "付款日期") + private LocalDate paymentDate; + + @Schema(description = "付款备注") + private String paymentRemark; + + @Schema(description = "备注") + private String remark; + + /** + * 报销明细项 + */ + @Data + public static class ReimbursementItem { + private Long id; + private String type; + private String description; + private BigDecimal amount; + private LocalDate occurDate; + private List attachments; + } +} diff --git a/src/main/java/com/nanxiislet/admin/entity/FinanceSettlement.java b/src/main/java/com/nanxiislet/admin/entity/FinanceSettlement.java new file mode 100644 index 0000000..2efb643 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/FinanceSettlement.java @@ -0,0 +1,80 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 结算记录实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("finance_settlement") +@Schema(description = "结算记录") +public class FinanceSettlement extends BaseEntity { + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "人才ID") + private Long talentId; + + @Schema(description = "人才名称") + private String talentName; + + @Schema(description = "结算周期/月份") + private String period; + + @Schema(description = "项目总额") + private BigDecimal totalAmount; + + @Schema(description = "平台服务费") + private BigDecimal platformFee; + + @Schema(description = "应纳税所得额") + private BigDecimal taxableAmount; + + @Schema(description = "税率") + private BigDecimal taxRate; + + @Schema(description = "扣税金额") + private BigDecimal taxAmount; + + @Schema(description = "实发金额") + private BigDecimal actualAmount; + + @Schema(description = "状态 pending/paying/completed/rejected") + private String status; + + @Schema(description = "发票状态 none/pending/received") + private String invoiceStatus; + + @Schema(description = "银行户名") + private String bankAccountName; + + @Schema(description = "银行账号") + private String bankAccountNo; + + @Schema(description = "开户银行") + private String bankName; + + @Schema(description = "审核时间") + private LocalDate auditTime; + + @Schema(description = "打款时间") + private LocalDate paymentTime; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/PlatformCertificate.java b/src/main/java/com/nanxiislet/admin/entity/PlatformCertificate.java new file mode 100644 index 0000000..cf36238 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/PlatformCertificate.java @@ -0,0 +1,89 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 证书实体 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("platform_certificate") +@Schema(description = "证书信息") +public class PlatformCertificate extends BaseEntity { + + @Schema(description = "关联服务器ID") + private Long serverId; + + @Schema(description = "服务器名称") + private String serverName; + + @Schema(description = "1Panel证书ID") + private Long panelSslId; + + @Schema(description = "主域名") + private String primaryDomain; + + @Schema(description = "其他域名(逗号分隔)") + private String otherDomains; + + @Schema(description = "证书主体名称") + private String cn; + + @Schema(description = "颁发组织") + private String organization; + + @Schema(description = "验证方式 dnsAccount/httpManual") + private String provider; + + @Schema(description = "Acme账户ID") + private Long acmeAccountId; + + @Schema(description = "Acme账户邮箱") + private String acmeAccountEmail; + + @Schema(description = "DNS账户ID") + private Long dnsAccountId; + + @Schema(description = "DNS账户名称") + private String dnsAccountName; + + @Schema(description = "DNS账户类型") + private String dnsAccountType; + + @Schema(description = "密钥算法 P256/P384/RSA2048/RSA4096") + private String keyType; + + @Schema(description = "状态 pending/valid/expired/error") + private String status; + + @Schema(description = "自动续签") + private Boolean autoRenew; + + @Schema(description = "生效时间") + private LocalDate startDate; + + @Schema(description = "过期时间") + private LocalDate expireDate; + + @Schema(description = "证书内容") + private String certContent; + + @Schema(description = "私钥内容") + private String keyContent; + + @Schema(description = "备注") + private String description; + + @Schema(description = "最后同步时间") + private LocalDateTime lastSyncTime; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/PlatformDomain.java b/src/main/java/com/nanxiislet/admin/entity/PlatformDomain.java new file mode 100644 index 0000000..9e51f71 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/PlatformDomain.java @@ -0,0 +1,115 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** + * 域名实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("platform_domain") +@Schema(description = "域名信息") +public class PlatformDomain extends BaseEntity { + + @Schema(description = "域名") + private String domain; + + @Schema(description = "关联项目ID") + private Long projectId; + + @Schema(description = "关联项目名称") + private String projectName; + + @Schema(description = "关联服务器ID") + private Long serverId; + + @Schema(description = "关联服务器名称") + private String serverName; + + @Schema(description = "服务器IP") + private String serverIp; + + @Schema(description = "状态 active/pending/expired/error") + private String status; + + @Schema(description = "DNS状态 resolved/unresolved/checking") + private String dnsStatus; + + @Schema(description = "DNS记录(JSON格式)") + private String dnsRecords; + + @Schema(description = "SSL状态 valid/expiring/expired/none") + private String sslStatus; + + @Schema(description = "SSL过期时间") + private LocalDate sslExpireDate; + + @Schema(description = "证书ID") + private String certificateId; + + @Schema(description = "证书名称") + private String certificateName; + + @Schema(description = "Nginx配置路径") + private String nginxConfigPath; + + @Schema(description = "代理地址") + private String proxyPass; + + @Schema(description = "端口") + private Integer port; + + @Schema(description = "是否启用HTTPS") + private Boolean enableHttps; + + @Schema(description = "是否强制HTTPS") + private Boolean forceHttps; + + @Schema(description = "描述") + private String description; + + @Schema(description = "1Panel网站ID") + private Long panelWebsiteId; + + @Schema(description = "1Panel证书ID") + private Long panelSslId; + + @Schema(description = "网站目录路径") + private String sitePath; + + @Schema(description = "网站别名") + private String alias; + + @Schema(description = "部署状态 not_deployed/deploying/deployed/failed") + private String deployStatus; + + @Schema(description = "最后部署时间") + private java.time.LocalDateTime lastDeployTime; + + @Schema(description = "最后部署消息") + private String lastDeployMessage; + + @Schema(description = "关联运行环境ID") + private Long runtimeId; + + @Schema(description = "运行环境所属服务器ID") + private Long runtimeServerId; + + @Schema(description = "运行环境名称") + private String runtimeName; + + @Schema(description = "运行环境类型 java/node") + private String runtimeType; + + @Schema(description = "运行环境部署状态 not_deployed/deploying/deployed/failed") + private String runtimeDeployStatus; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/PlatformProject.java b/src/main/java/com/nanxiislet/admin/entity/PlatformProject.java new file mode 100644 index 0000000..513ab5f --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/PlatformProject.java @@ -0,0 +1,107 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 项目实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("platform_project") +@Schema(description = "项目信息") +public class PlatformProject extends BaseEntity { + + @Schema(description = "项目名称") + private String name; + + @Schema(description = "项目编码") + private String code; + + @Schema(description = "项目简称") + private String shortName; + + @Schema(description = "项目类型") + private String type; + + @Schema(description = "项目分组 default/business/internal/test") + private String projectGroup; + + @Schema(description = "Logo文字") + private String logo; + + @Schema(description = "主题色") + private String color; + + @Schema(description = "绑定域名") + private String domain; + + @Schema(description = "访问地址") + private String url; + + @Schema(description = "关联域名ID") + private Long domainId; + + @Schema(description = "关联服务器ID") + private Long serverId; + + @Schema(description = "服务器名称") + private String serverName; + + @Schema(description = "部署路径") + private String deployPath; + + @Schema(description = "当前版本") + private String version; + + @Schema(description = "状态 active/inactive/deploying") + private String status; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "描述") + private String description; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "是否启用HTTPS") + private Boolean enableHttps; + + @Schema(description = "1Panel网站ID") + private Long panelWebsiteId; + + @Schema(description = "1Panel证书ID") + private Long panelSslId; + + @Schema(description = "最后部署时间") + private LocalDateTime lastDeployTime; + + @Schema(description = "最后部署状态 success/failed/deploying") + private String lastDeployStatus; + + @Schema(description = "最后部署消息") + private String lastDeployMessage; + + @Schema(description = "系统类型 admin/portal(字典项system)") + private String systemType; + + @Schema(description = "是否集成到框架(仅管理端有效,字典项integration)") + private Boolean integrateToFramework; + + @Schema(description = "菜单数量") + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private Long menuCount; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/PlatformServer.java b/src/main/java/com/nanxiislet/admin/entity/PlatformServer.java new file mode 100644 index 0000000..6f76b75 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/PlatformServer.java @@ -0,0 +1,67 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 服务器实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("platform_server") +@Schema(description = "服务器信息") +public class PlatformServer extends BaseEntity { + + @Schema(description = "服务器名称") + private String name; + + @Schema(description = "公网IP") + private String ip; + + @Schema(description = "内网IP") + private String internalIp; + + @Schema(description = "SSH端口") + private Integer port; + + @Schema(description = "服务器类型 physical/virtual/cloud") + private String type; + + @Schema(description = "状态 online/offline/warning/maintenance") + private String status; + + @Schema(description = "操作系统") + private String os; + + @Schema(description = "CPU核心数") + private Integer cpuCores; + + @Schema(description = "内存大小(GB)") + private Integer memoryTotal; + + @Schema(description = "磁盘大小(GB)") + private Integer diskTotal; + + @Schema(description = "标签(JSON格式)") + private String tags; + + + + @Schema(description = "1Panel面板地址") + private String panelUrl; + + @Schema(description = "1Panel面板端口") + private Integer panelPort; + + @Schema(description = "1Panel API密钥") + private String panelApiKey; + + @Schema(description = "描述") + private String description; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysApprovalInstance.java b/src/main/java/com/nanxiislet/admin/entity/SysApprovalInstance.java new file mode 100644 index 0000000..1990a94 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysApprovalInstance.java @@ -0,0 +1,107 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 审批实例实体 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@TableName("sys_approval_instance") +public class SysApprovalInstance { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 模板ID + */ + private Long templateId; + + /** + * 模板名称 + */ + private String templateName; + + /** + * 适用场景 + */ + private String scenario; + + /** + * 业务类型 + */ + private String businessType; + + /** + * 业务ID + */ + private Long businessId; + + /** + * 业务标题 + */ + private String businessTitle; + + /** + * 发起人ID + */ + private Long initiatorId; + + /** + * 发起人名称 + */ + private String initiatorName; + + /** + * 发起人头像 + */ + private String initiatorAvatar; + + /** + * 状态: pending-待提交 in_progress-审批中 approved-已通过 rejected-已拒绝 withdrawn-已撤回 cancelled-已取消 + */ + private String status; + + /** + * 当前节点ID + */ + private Long currentNodeId; + + /** + * 当前节点名称 + */ + private String currentNodeName; + + /** + * 提交时间 + */ + private LocalDateTime submittedAt; + + /** + * 完成时间 + */ + private LocalDateTime completedAt; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + + /** + * 删除标记 + */ + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysApprovalNode.java b/src/main/java/com/nanxiislet/admin/entity/SysApprovalNode.java new file mode 100644 index 0000000..6c8f40e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysApprovalNode.java @@ -0,0 +1,89 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 审批流程节点实体 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@TableName(value = "sys_approval_node", autoResultMap = true) +public class SysApprovalNode { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 模板ID + */ + private Long templateId; + + /** + * 节点名称 + */ + private String name; + + /** + * 审批人类型: specified-指定人员 role-按角色 superior-上级领导 self_select-发起人自选 + */ + private String approverType; + + /** + * 审批人ID列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List approverIds; + + /** + * 审批角色编码 + */ + private String approverRole; + + /** + * 部门ID(按部门时使用) + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 审批方式: and-会签 or-或签 + */ + private String approvalMode; + + /** + * 超时时间(小时) + */ + private Integer timeoutHours; + + /** + * 超时操作: skip-跳过 reject-驳回 + */ + private String timeoutAction; + + /** + * 节点顺序 + */ + private Integer sort; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysApprovalRecord.java b/src/main/java/com/nanxiislet/admin/entity/SysApprovalRecord.java new file mode 100644 index 0000000..425a95d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysApprovalRecord.java @@ -0,0 +1,64 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 审批记录实体 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@TableName("sys_approval_record") +public class SysApprovalRecord { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 实例ID + */ + private Long instanceId; + + /** + * 节点ID + */ + private Long nodeId; + + /** + * 节点名称 + */ + private String nodeName; + + /** + * 审批人ID + */ + private Long approverId; + + /** + * 审批人名称 + */ + private String approverName; + + /** + * 审批人头像 + */ + private String approverAvatar; + + /** + * 操作: approve-通过 reject-驳回 transfer-转交 return-退回 + */ + private String action; + + /** + * 审批意见 + */ + private String comment; + + /** + * 操作时间 + */ + private LocalDateTime operatedAt; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysApprovalTemplate.java b/src/main/java/com/nanxiislet/admin/entity/SysApprovalTemplate.java new file mode 100644 index 0000000..4e8ea17 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysApprovalTemplate.java @@ -0,0 +1,69 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 审批流程模板实体 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@TableName("sys_approval_template") +public class SysApprovalTemplate { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 模板名称 + */ + private String name; + + /** + * 描述 + */ + private String description; + + /** + * 适用场景 + */ + private String scenario; + + /** + * 是否启用 0-禁用 1-启用 + */ + private Integer enabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + + /** + * 创建人ID + */ + @TableField(fill = FieldFill.INSERT) + private Long createdBy; + + /** + * 更新人ID + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updatedBy; + + /** + * 删除标记 + */ + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysDept.java b/src/main/java/com/nanxiislet/admin/entity/SysDept.java new file mode 100644 index 0000000..bc2891c --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysDept.java @@ -0,0 +1,106 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 部门实体 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Data +@TableName("sys_dept") +public class SysDept { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 父部门ID,0表示顶级部门 + */ + private Long parentId; + + /** + * 部门名称 + */ + private String name; + + /** + * 部门编码 + */ + private String code; + + /** + * 部门负责人ID + */ + private Long leaderId; + + /** + * 部门负责人名称 + */ + private String leaderName; + + /** + * 联系电话 + */ + private String phone; + + /** + * 邮箱 + */ + private String email; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态 0-禁用 1-正常 + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + + /** + * 创建人ID + */ + @TableField(fill = FieldFill.INSERT) + private Long createdBy; + + /** + * 更新人ID + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updatedBy; + + /** + * 删除标记 + */ + @TableLogic + private Integer deleted; + + /** + * 子部门列表(非数据库字段) + */ + @TableField(exist = false) + private List children; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysDict.java b/src/main/java/com/nanxiislet/admin/entity/SysDict.java new file mode 100644 index 0000000..ae0ef38 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysDict.java @@ -0,0 +1,32 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 字典类型实体 + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_dict") +@Schema(description = "字典类型") +public class SysDict extends BaseEntity { + + @Schema(description = "字典名称") + private String name; + + @Schema(description = "字典编码") + private String code; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysDictItem.java b/src/main/java/com/nanxiislet/admin/entity/SysDictItem.java new file mode 100644 index 0000000..caf3c32 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysDictItem.java @@ -0,0 +1,41 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 字典项实体 + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_dict_item") +@Schema(description = "字典项") +public class SysDictItem extends BaseEntity { + + @Schema(description = "字典ID") + private Long dictId; + + @Schema(description = "字典项标签") + private String label; + + @Schema(description = "字典项值") + private String value; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "是否默认 0-否 1-是") + private Integer isDefault; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysMenu.java b/src/main/java/com/nanxiislet/admin/entity/SysMenu.java new file mode 100644 index 0000000..4a2007e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysMenu.java @@ -0,0 +1,59 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 菜单实体 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_menu") +@Schema(description = "菜单信息") +public class SysMenu extends BaseEntity { + + @Schema(description = "关联项目ID") + private Long projectId; + + @Schema(description = "父菜单ID") + private Long parentId; + + @Schema(description = "菜单名称") + private String name; + + @Schema(description = "菜单编码/Key") + private String code; + + @Schema(description = "菜单类型 directory-目录 menu-菜单 button-按钮") + private String type; + + @Schema(description = "路由路径") + private String path; + + @Schema(description = "组件路径") + private String component; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "权限标识") + private String permission; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "是否隐藏 0-显示 1-隐藏") + private Integer hidden; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysRole.java b/src/main/java/com/nanxiislet/admin/entity/SysRole.java new file mode 100644 index 0000000..efdef0b --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysRole.java @@ -0,0 +1,35 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色实体 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_role") +@Schema(description = "角色信息") +public class SysRole extends BaseEntity { + + @Schema(description = "角色编码") + private String code; + + @Schema(description = "角色名称") + private String name; + + @Schema(description = "角色描述") + private String description; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysRoleMenu.java b/src/main/java/com/nanxiislet/admin/entity/SysRoleMenu.java new file mode 100644 index 0000000..f9114b6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysRoleMenu.java @@ -0,0 +1,35 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 角色菜单关联实体 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Data +@TableName("sys_role_menu") +@Schema(description = "角色菜单关联") +public class SysRoleMenu implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "角色ID") + private Long roleId; + + @Schema(description = "菜单ID") + private Long menuId; +} diff --git a/src/main/java/com/nanxiislet/admin/entity/SysUser.java b/src/main/java/com/nanxiislet/admin/entity/SysUser.java new file mode 100644 index 0000000..1f39dcd --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/entity/SysUser.java @@ -0,0 +1,59 @@ +package com.nanxiislet.admin.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.nanxiislet.admin.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户实体 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_user") +@Schema(description = "用户信息") +public class SysUser extends BaseEntity { + + @Schema(description = "用户名") + private String username; + + @Schema(description = "密码") + private String password; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "角色编码") + private String role; + + @Schema(description = "部门ID") + private Long deptId; + + @Schema(description = "部门名称") + private String deptName; + + @Schema(description = "状态 0-禁用 1-正常") + private Integer status; + + @Schema(description = "最后登录时间") + private java.time.LocalDateTime lastLoginTime; + + @Schema(description = "最后登录IP") + private String lastLoginIp; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceAccountMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceAccountMapper.java new file mode 100644 index 0000000..c86c755 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceAccountMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceAccount; +import org.apache.ibatis.annotations.Mapper; + +/** + * 财务账户 Mapper + */ +@Mapper +public interface FinanceAccountMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceBudgetMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceBudgetMapper.java new file mode 100644 index 0000000..5ebfb7d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceBudgetMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceBudget; +import org.apache.ibatis.annotations.Mapper; + +/** + * 预算记录 Mapper + */ +@Mapper +public interface FinanceBudgetMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceExpenseMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceExpenseMapper.java new file mode 100644 index 0000000..9ccd865 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceExpenseMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceExpense; +import org.apache.ibatis.annotations.Mapper; + +/** + * 支出记录 Mapper + */ +@Mapper +public interface FinanceExpenseMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceIncomeMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceIncomeMapper.java new file mode 100644 index 0000000..e474f39 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceIncomeMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceIncome; +import org.apache.ibatis.annotations.Mapper; + +/** + * 收入记录 Mapper + */ +@Mapper +public interface FinanceIncomeMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceInvoiceMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceInvoiceMapper.java new file mode 100644 index 0000000..afbc9f4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceInvoiceMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceInvoice; +import org.apache.ibatis.annotations.Mapper; + +/** + * 发票记录 Mapper + */ +@Mapper +public interface FinanceInvoiceMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceReimbursementMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceReimbursementMapper.java new file mode 100644 index 0000000..05067f6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceReimbursementMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceReimbursement; +import org.apache.ibatis.annotations.Mapper; + +/** + * 报销记录 Mapper + */ +@Mapper +public interface FinanceReimbursementMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/FinanceSettlementMapper.java b/src/main/java/com/nanxiislet/admin/mapper/FinanceSettlementMapper.java new file mode 100644 index 0000000..880ca66 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/FinanceSettlementMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.FinanceSettlement; +import org.apache.ibatis.annotations.Mapper; + +/** + * 结算记录 Mapper + */ +@Mapper +public interface FinanceSettlementMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/PlatformCertificateMapper.java b/src/main/java/com/nanxiislet/admin/mapper/PlatformCertificateMapper.java new file mode 100644 index 0000000..aacc0f5 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/PlatformCertificateMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.PlatformCertificate; +import org.apache.ibatis.annotations.Mapper; + +/** + * 证书 Mapper + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Mapper +public interface PlatformCertificateMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/PlatformDomainMapper.java b/src/main/java/com/nanxiislet/admin/mapper/PlatformDomainMapper.java new file mode 100644 index 0000000..6b93a9a --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/PlatformDomainMapper.java @@ -0,0 +1,18 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.PlatformDomain; +import org.apache.ibatis.annotations.Mapper; + +/** + * 域名 Mapper + */ +@Mapper +public interface PlatformDomainMapper extends BaseMapper { + + @org.apache.ibatis.annotations.Select("SELECT * FROM platform_domain WHERE domain = #{domain} LIMIT 1") + PlatformDomain findByDomainIncludeDeleted(@org.apache.ibatis.annotations.Param("domain") String domain); + + @org.apache.ibatis.annotations.Update("UPDATE platform_domain SET deleted = 0, updated_at = NOW() WHERE id = #{id}") + void restoreById(@org.apache.ibatis.annotations.Param("id") Long id); +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/PlatformProjectMapper.java b/src/main/java/com/nanxiislet/admin/mapper/PlatformProjectMapper.java new file mode 100644 index 0000000..8834038 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/PlatformProjectMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.PlatformProject; +import org.apache.ibatis.annotations.Mapper; + +/** + * 项目 Mapper + */ +@Mapper +public interface PlatformProjectMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/PlatformServerMapper.java b/src/main/java/com/nanxiislet/admin/mapper/PlatformServerMapper.java new file mode 100644 index 0000000..e291ece --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/PlatformServerMapper.java @@ -0,0 +1,12 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.PlatformServer; +import org.apache.ibatis.annotations.Mapper; + +/** + * 服务器 Mapper + */ +@Mapper +public interface PlatformServerMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysApprovalInstanceMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalInstanceMapper.java new file mode 100644 index 0000000..1baed41 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalInstanceMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysApprovalInstance; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审批实例Mapper + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Mapper +public interface SysApprovalInstanceMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysApprovalNodeMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalNodeMapper.java new file mode 100644 index 0000000..ef6f0a2 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalNodeMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysApprovalNode; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审批流程节点Mapper + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Mapper +public interface SysApprovalNodeMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysApprovalRecordMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalRecordMapper.java new file mode 100644 index 0000000..937c646 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalRecordMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysApprovalRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审批记录Mapper + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Mapper +public interface SysApprovalRecordMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysApprovalTemplateMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalTemplateMapper.java new file mode 100644 index 0000000..9cba0a1 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysApprovalTemplateMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysApprovalTemplate; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审批流程模板Mapper + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Mapper +public interface SysApprovalTemplateMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysDeptMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysDeptMapper.java new file mode 100644 index 0000000..58b7d28 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysDeptMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysDept; +import org.apache.ibatis.annotations.Mapper; + +/** + * 部门Mapper + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Mapper +public interface SysDeptMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysDictItemMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysDictItemMapper.java new file mode 100644 index 0000000..e15f406 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysDictItemMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysDictItem; +import org.apache.ibatis.annotations.Mapper; + +/** + * 字典项 Mapper + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Mapper +public interface SysDictItemMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysDictMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysDictMapper.java new file mode 100644 index 0000000..fbea3e4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysDictMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysDict; +import org.apache.ibatis.annotations.Mapper; + +/** + * 字典类型 Mapper + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Mapper +public interface SysDictMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysMenuMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysMenuMapper.java new file mode 100644 index 0000000..a87040a --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysMenuMapper.java @@ -0,0 +1,38 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysMenu; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 菜单Mapper + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Mapper +public interface SysMenuMapper extends BaseMapper { + + /** + * 根据角色ID查询菜单 + */ + @Select("SELECT m.* FROM sys_menu m " + + "INNER JOIN sys_role_menu rm ON m.id = rm.menu_id " + + "WHERE rm.role_id = #{roleId} AND m.deleted = 0 " + + "ORDER BY m.sort") + List selectMenusByRoleId(@Param("roleId") Long roleId); + + /** + * 根据角色编码查询菜单 + */ + @Select("SELECT m.* FROM sys_menu m " + + "INNER JOIN sys_role_menu rm ON m.id = rm.menu_id " + + "INNER JOIN sys_role r ON rm.role_id = r.id " + + "WHERE r.code = #{roleCode} AND m.deleted = 0 AND r.deleted = 0 " + + "ORDER BY m.sort") + List selectMenusByRoleCode(@Param("roleCode") String roleCode); +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysRoleMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysRoleMapper.java new file mode 100644 index 0000000..6723ada --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysRoleMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 角色Mapper + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Mapper +public interface SysRoleMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysRoleMenuMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysRoleMenuMapper.java new file mode 100644 index 0000000..3604182 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysRoleMenuMapper.java @@ -0,0 +1,26 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysRoleMenu; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 角色菜单关联Mapper + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Mapper +public interface SysRoleMenuMapper extends BaseMapper { + + /** + * 根据角色ID删除关联 + */ + int deleteByRoleId(@Param("roleId") Long roleId); + + /** + * 根据菜单ID删除关联 + */ + int deleteByMenuId(@Param("menuId") Long menuId); +} diff --git a/src/main/java/com/nanxiislet/admin/mapper/SysUserMapper.java b/src/main/java/com/nanxiislet/admin/mapper/SysUserMapper.java new file mode 100644 index 0000000..bbd95e6 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/mapper/SysUserMapper.java @@ -0,0 +1,15 @@ +package com.nanxiislet.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanxiislet.admin.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户 Mapper + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Mapper +public interface SysUserMapper extends BaseMapper { +} diff --git a/src/main/java/com/nanxiislet/admin/service/AuthService.java b/src/main/java/com/nanxiislet/admin/service/AuthService.java new file mode 100644 index 0000000..a225160 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/AuthService.java @@ -0,0 +1,49 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.dto.auth.*; +import com.nanxiislet.admin.entity.SysUser; + +/** + * 认证服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface AuthService extends IService { + + /** + * 生成验证码 + * + * @return 验证码响应 + */ + CaptchaResponse generateCaptcha(); + + /** + * 用户登录 + * + * @param request 登录请求 + * @return 登录响应 + */ + LoginResponse login(LoginRequest request); + + /** + * 用户登出 + */ + void logout(); + + /** + * 获取当前登录用户信息 + * + * @return 用户信息 + */ + UserInfoVO getCurrentUser(); + + /** + * 根据用户名查询用户 + * + * @param username 用户名 + * @return 用户信息 + */ + SysUser getByUsername(String username); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceAccountService.java b/src/main/java/com/nanxiislet/admin/service/FinanceAccountService.java new file mode 100644 index 0000000..a2264f7 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceAccountService.java @@ -0,0 +1,14 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.FinanceAccount; + +import java.util.List; + +/** + * 财务账户服务接口 + */ +public interface FinanceAccountService extends IService { + List listActive(); + FinanceAccount getDefault(); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceBudgetService.java b/src/main/java/com/nanxiislet/admin/service/FinanceBudgetService.java new file mode 100644 index 0000000..5c57414 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceBudgetService.java @@ -0,0 +1,53 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceBudget; + +import java.math.BigDecimal; + +/** + * 预算管理服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface FinanceBudgetService extends IService { + + /** + * 分页查询预算记录 + * + * @param query 查询条件 + * @return 分页结果 + */ + Page listPage(BasePageQuery query); + + /** + * 按条件查询预算 + * + * @param year 年份 + * @param period 周期 + * @param quarter 季度 + * @param month 月份 + * @param departmentId 部门ID + * @param projectId 项目ID + * @return 预算记录 + */ + FinanceBudget findBudget(Integer year, String period, Integer quarter, Integer month, Long departmentId, Long projectId); + + /** + * 更新预算使用金额 + * + * @param budgetId 预算ID + * @param usedAmount 新增使用金额 + */ + void updateUsedAmount(Long budgetId, BigDecimal usedAmount); + + /** + * 计算并更新使用率 + * + * @param budgetId 预算ID + */ + void calculateUsageRate(Long budgetId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceExpenseService.java b/src/main/java/com/nanxiislet/admin/service/FinanceExpenseService.java new file mode 100644 index 0000000..8bf016d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceExpenseService.java @@ -0,0 +1,14 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceExpense; + +/** + * 支出管理服务接口 + */ +public interface FinanceExpenseService extends IService { + Page listPage(BasePageQuery query); + String generateExpenseNo(); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceIncomeService.java b/src/main/java/com/nanxiislet/admin/service/FinanceIncomeService.java new file mode 100644 index 0000000..6e3371a --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceIncomeService.java @@ -0,0 +1,30 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceIncome; + +/** + * 收入管理服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface FinanceIncomeService extends IService { + + /** + * 分页查询收入记录 + * + * @param query 查询条件 + * @return 分页结果 + */ + Page listPage(BasePageQuery query); + + /** + * 生成收入编号 + * + * @return 收入编号 + */ + String generateIncomeNo(); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceInvoiceService.java b/src/main/java/com/nanxiislet/admin/service/FinanceInvoiceService.java new file mode 100644 index 0000000..c4b6b06 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceInvoiceService.java @@ -0,0 +1,38 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceInvoice; + +/** + * 发票管理服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface FinanceInvoiceService extends IService { + + /** + * 分页查询发票记录 + * + * @param query 查询条件 + * @return 分页结果 + */ + Page listPage(BasePageQuery query); + + /** + * 开具发票 + * + * @param id 发票ID + */ + void issueInvoice(Long id); + + /** + * 驳回发票申请 + * + * @param id 发票ID + * @param reason 驳回原因 + */ + void rejectInvoice(Long id, String reason); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceReimbursementService.java b/src/main/java/com/nanxiislet/admin/service/FinanceReimbursementService.java new file mode 100644 index 0000000..ed89c02 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceReimbursementService.java @@ -0,0 +1,14 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceReimbursement; + +/** + * 报销管理服务接口 + */ +public interface FinanceReimbursementService extends IService { + Page listPage(BasePageQuery query); + String generateReimbursementNo(); +} diff --git a/src/main/java/com/nanxiislet/admin/service/FinanceSettlementService.java b/src/main/java/com/nanxiislet/admin/service/FinanceSettlementService.java new file mode 100644 index 0000000..74f7a16 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/FinanceSettlementService.java @@ -0,0 +1,52 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceSettlement; + +/** + * 结算管理服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface FinanceSettlementService extends IService { + + /** + * 分页查询结算记录 + * + * @param query 查询条件 + * @return 分页结果 + */ + Page listPage(BasePageQuery query); + + /** + * 审核通过 + * + * @param id 结算单ID + */ + void approve(Long id); + + /** + * 驳回 + * + * @param id 结算单ID + * @param reason 驳回原因 + */ + void reject(Long id, String reason); + + /** + * 确认打款 + * + * @param id 结算单ID + */ + void confirmPayment(Long id); + + /** + * 计算税金和实发金额 + * + * @param settlement 结算单 + */ + void calculateAmounts(FinanceSettlement settlement); +} diff --git a/src/main/java/com/nanxiislet/admin/service/OnePanelService.java b/src/main/java/com/nanxiislet/admin/service/OnePanelService.java new file mode 100644 index 0000000..4ee81e8 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/OnePanelService.java @@ -0,0 +1,437 @@ +package com.nanxiislet.admin.service; + +import com.nanxiislet.admin.dto.DeployRequest; +import com.nanxiislet.admin.dto.DeployResult; + +/** + * 1Panel服务接口 + * 用于与1Panel服务器进行交互 + * + * @author NanxiIslet + * @since 2024-01-10 + */ +public interface OnePanelService { + + /** + * 执行项目部署 + * + * @param request 部署请求 + * @return 部署结果 + */ + DeployResult deployProject(DeployRequest request); + + /** + * 获取网站列表 + * + * @param serverId 服务器ID + * @return 网站列表JSON字符串 + */ + String getWebsites(Long serverId); + + /** + * 获取证书列表 + * + * @param serverId 服务器ID + * @return 证书列表JSON字符串 + */ + String getCertificates(Long serverId); + + /** + * 检查网站是否已在1Panel中存在 + * + * @param serverId 服务器ID + * @param domain 域名 + * @return 网站ID,如果不存在返回null + */ + Long checkWebsiteExists(Long serverId, String domain); + + /** + * 检查网站状态并返回详细信息 + * + * @param serverId 服务器ID + * @param domain 域名 + * @return 网站详细信息Map,包含 id, status, sslStatus, protocol 等 + */ + java.util.Map checkWebsiteStatus(Long serverId, String domain); + + /** + * 操作网站(启动/停止) + * + * @param serverId 服务器ID + * @param websiteId 网站ID + * @param operate 操作类型:start, stop + * @return 是否成功 + */ + boolean operateWebsite(Long serverId, Long websiteId, String operate); + + /** + * 删除网站 + * + * @param serverId 服务器ID + * @param websiteId 网站ID + * @return 是否成功 + */ + boolean deleteWebsite(Long serverId, Long websiteId); + + /** + * 批量检查文件是否存在 + * + * @param serverId 服务器ID + * @param paths 文件路径列表 + * @return 存在的重复文件列表 + */ + java.util.List checkFileBatch(Long serverId, java.util.List paths); + + /** + * 查询文件/目录详情 + * + * @param serverId 服务器ID + * @param path 路径 + * @return 文件详情 JSON 字符串 + */ + String searchFile(Long serverId, String path); + + /** + * 删除文件或目录 + * + * @param serverId 服务器ID + * @param path 路径 + * @param isDir 是否目录 + * @return 是否成功 + */ + boolean deleteFile(Long serverId, String path, boolean isDir); + + /** + * 分片上传文件 + * + * @param serverId 服务器ID + * @param filename 文件名 + * @param path 目标目录路径 + * @param chunkIndex 分片索引 + * @param chunkCount 分片总数 + * @param fileContent 分片内容(字节数组) + * @return 是否上传成功(成功返回null或空消息,失败返回错误信息) + */ + String uploadFileChunk(Long serverId, String filename, String path, int chunkIndex, int chunkCount, org.springframework.web.multipart.MultipartFile fileContent); + + /** + * 检查1Panel应用安装状态 + * + * @param serverId 服务器ID + * @param key 应用key,如mysql, postgresql, redis + * @param name 应用名称 + * @return 应用安装状态信息Map + */ + java.util.Map checkAppInstalled(Long serverId, String key, String name); + + /** + * 查询数据库列表 + * + * @param serverId 服务器ID + * @param database 数据库类型,如mysql + * @param page 页码 + * @param pageSize 每页数量 + * @return 数据库列表信息 + */ + java.util.Map searchDatabases(Long serverId, String database, int page, int pageSize); + + /** + * 创建数据库 + * + * @param serverId 服务器ID + * @param params 创建参数 + * @return 创建结果 + */ + java.util.Map createDatabase(Long serverId, java.util.Map params); + + /** + * 删除数据库 + * + * @param serverId 服务器ID + * @param params 删除参数 + * @return 是否成功 + */ + boolean deleteDatabase(Long serverId, java.util.Map params); + + /** + * 修改数据库描述 + * + * @param serverId 服务器ID + * @param params 修改参数 + * @return 是否成功 + */ + boolean updateDatabaseDescription(Long serverId, java.util.Map params); + + /** + * 修改数据库密码 + * + * @param serverId 服务器ID + * @param params 修改参数 + * @return 是否成功 + */ + boolean changeDatabasePassword(Long serverId, java.util.Map params); + + /** + * 操作应用(启动/停止/重启) + * + * @param serverId 服务器ID + * @param params 操作参数 + * @return 是否成功 + */ + boolean operateApp(Long serverId, java.util.Map params); + + /** + * 获取数据库字符集排序规则选项 + * + * @param serverId 服务器ID + * @param type 数据库类型 + * @param database 数据库名称 + * @param format 字符集 (可选) + * @return 选项列表 + */ + java.util.List getDatabaseFormatOptions(Long serverId, String type, String database, String format); + + /** + * 获取应用信息(如Redis) + * + * @param serverId 服务器ID + * @param appKey 应用key,如redis, mysql等 + * @return 应用信息Map + */ + java.util.Map getAppInfo(Long serverId, String appKey); + + /** + * 获取应用版本详情 + * + * @param serverId 服务器ID + * @param appId 应用ID + * @param version 版本号 + * @return 应用版本详情Map + */ + java.util.Map getAppDetail(Long serverId, Long appId, String version); + + /** + * 安装应用 + * + * @param serverId 服务器ID + * @param params 安装参数 + * @return 安装结果 + */ + java.util.Map installApp(Long serverId, java.util.Map params); + + /** + * 读取任务日志 + * + * @param serverId 服务器ID + * @param taskId 任务ID + * @param page 页码 + * @param pageSize 每页数量 + * @return 日志内容 + */ + java.util.Map readTaskLog(Long serverId, String taskId, int page, int pageSize); + + /** + * 搜索运行时列表 + * + * @param serverId 服务器ID + * @param type 运行时类型(php/java/node/go/python/dotnet) + * @param page 页码 + * @param pageSize 每页数量 + * @return 运行时列表 + */ + java.util.Map searchRuntimes(Long serverId, String type, int page, int pageSize); + + /** + * 同步运行时状态 + * + * @param serverId 服务器ID + * @return 结果 + */ + java.util.Map syncRuntimes(Long serverId); + + /** + * 操作运行时(启动/停止/重启) + * + * @param serverId 服务器ID + * @param id 运行时ID + * @param operate 操作类型(start/stop/restart) + * @return 结果 + */ + java.util.Map operateRuntime(Long serverId, Long id, String operate); + + /** + * 删除运行时 + * + * @param serverId 服务器ID + * @param id 运行时ID + * @param forceDelete 是否强制删除 + * @param deleteFolder 是否删除关联文件夹 + * @param codeDir 文件夹路径 + * @return 结果 + */ + java.util.Map deleteRuntime(Long serverId, Long id, boolean forceDelete, boolean deleteFolder, String codeDir); + + /** + * 搜索运行时应用列表 + * + * @param serverId 服务器ID + * @param type 运行时类型(php/java/node/go/python/dotnet) + * @param page 页码 + * @param pageSize 每页数量 + * @return 应用列表 + */ + java.util.Map searchRuntimeApps(Long serverId, String type, int page, int pageSize); + + /** + * 获取运行时版本详情 + * + * @param serverId 服务器ID + * @param appId 应用ID + * @param version 版本号 + * @return 版本详情 + */ + java.util.Map getRuntimeDetail(Long serverId, Long appId, String version); + + /** + * 创建运行时 + * + * @param serverId 服务器ID + * @param params 创建参数 + * @return 结果 + */ + java.util.Map createRuntime(Long serverId, java.util.Map params); + + /** + * 获取文件/目录列表 + * + * @param serverId 服务器ID + * @param path 路径 + * @return 文件列表 + */ + java.util.Map listFiles(Long serverId, String path); + + /** + * + * @param serverId 服务器ID + * @param path 路径 + * @return 结果 + */ + java.util.Map mkdir(Long serverId, String path); + + /** + * + * @param serverId 服务器ID + * @param path 路径 + * @param isDir 是否为目录 + * @return 结果 + */ + java.util.Map deleteFile(Long serverId, String path, Boolean isDir); + + /** + * 创建文件 + * + * @param serverId 服务器ID + * @param path 路径 + * @return 结果 + */ + java.util.Map createFile(Long serverId, String path); + + /** + * 上传文件 + * + * @param serverId 服务器ID + * @param path 上传目录 + * @param file 文件流 + * @return 结果 + */ + java.util.Map uploadFile(Long serverId, String path, org.springframework.web.multipart.MultipartFile file); + + /** + * 获取容器日志 + * + * @param serverId 服务器ID + * @param containerName 容器名称 + * @param composePath DockerCompose文件路径 (可选) + * @return 结果 + */ + java.util.Map getContainerLog(Long serverId, String containerName, String composePath); + + /** + * 更新运行时 + * + * @param serverId 服务器ID + * @param params 更新参数 + * @return 结果 + */ + java.util.Map updateRuntime(Long serverId, java.util.Map params); + + + /** + * 获取Node脚本列表 + * + * @param serverId 服务器ID + * @param codeDir 代码目录 + * @return 脚本列表 + */ + java.util.List> getNodeScripts(Long serverId, String codeDir); + + /** + * 分片上传文件到 1Panel(真正的分片上传) + * + * @param serverId 服务器ID + * @param path 目标目录路径 + * @param filename 文件名 + * @param chunkIndex 分片索引(从0开始) + * @param chunkCount 分片总数 + * @param fileSize 文件总大小(字节) + * @param uploadId 上传ID(用于标识同一个上传任务) + * @param chunk 分片内容 + * @return 结果 + */ + java.util.Map uploadChunk(Long serverId, String path, String filename, + int chunkIndex, int chunkCount, long fileSize, String uploadId, + org.springframework.web.multipart.MultipartFile chunk); + + /** + * 合并已上传的分片 + * + * @param serverId 服务器ID + * @param path 目标目录路径 + * @param filename 文件名 + * @param chunkCount 分片总数 + * @param fileSize 文件总大小(字节) + * @param uploadId 上传ID + * @return 结果 + */ + java.util.Map mergeChunks(Long serverId, String path, String filename, + int chunkCount, long fileSize, String uploadId); + + /** + * 获取网站Nginx配置 + * + * @param serverId 服务器ID + * @param websiteId 网站ID + * @return Nginx配置信息,包含 path, content, name 等 + */ + java.util.Map getWebsiteNginxConfig(Long serverId, Long websiteId); + + /** + * 保存网站Nginx配置 + * + * @param serverId 服务器ID + * @param websiteId 网站ID + * @param content 配置内容 + * @return 是否成功 + */ + boolean saveWebsiteNginxConfig(Long serverId, Long websiteId, String content); + + /** + * 重载Nginx + * + * @param serverId 服务器ID + * @return 是否成功 + */ + boolean reloadNginx(Long serverId); + +} + diff --git a/src/main/java/com/nanxiislet/admin/service/PlatformCertificateService.java b/src/main/java/com/nanxiislet/admin/service/PlatformCertificateService.java new file mode 100644 index 0000000..493f349 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/PlatformCertificateService.java @@ -0,0 +1,93 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.dto.CertificateApplyRequest; +import com.nanxiislet.admin.dto.CertificateApplyResult; +import com.nanxiislet.admin.entity.PlatformCertificate; + +import java.util.List; +import java.util.Map; + +/** + * 证书管理服务接口 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +public interface PlatformCertificateService extends IService { + + /** + * 分页查询证书列表 + */ + Page listPage(Long serverId, BasePageQuery query); + + /** + * 申请证书 + * 调用1Panel API申请证书,并保存到本地数据库 + * + * @param request 申请请求 + * @return 申请结果 + */ + CertificateApplyResult applyCertificate(CertificateApplyRequest request); + + /** + * 从1Panel同步证书列表 + * + * @param serverId 服务器ID + * @return 同步的证书数量 + */ + int syncCertificatesFromPanel(Long serverId); + + /** + * 获取证书详情(包含证书内容和私钥) + * + * @param id 证书ID + * @return 证书详情 + */ + PlatformCertificate getCertificateDetail(Long id); + + /** + * 删除证书 + * 同时删除1Panel上的证书 + * + * @param id 证书ID + * @return 是否成功 + */ + boolean deleteCertificate(Long id); + + /** + * 更新证书设置 + * + * @param id 证书ID + * @param autoRenew 是否自动续签 + * @param description 备注 + * @return 是否成功 + */ + boolean updateCertificateSettings(Long id, Boolean autoRenew, String description); + + /** + * 获取服务器的Acme账户列表 + * + * @param serverId 服务器ID + * @return Acme账户列表 + */ + List> getAcmeAccounts(Long serverId); + + /** + * 获取服务器的DNS账户列表 + * + * @param serverId 服务器ID + * @return DNS账户列表 + */ + List> getDnsAccounts(Long serverId); + + /** + * 获取服务器的网站列表 + * + * @param serverId 服务器ID + * @return 网站列表 + */ + List> getWebsites(Long serverId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/PlatformDomainService.java b/src/main/java/com/nanxiislet/admin/service/PlatformDomainService.java new file mode 100644 index 0000000..f9749b1 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/PlatformDomainService.java @@ -0,0 +1,91 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.dto.DomainDeployRequest; +import com.nanxiislet.admin.dto.DomainDeployResult; +import com.nanxiislet.admin.dto.DomainQueryDTO; +import com.nanxiislet.admin.dto.DomainStatsDTO; +import com.nanxiislet.admin.entity.PlatformDomain; + +import java.util.Map; + +/** + * 域名管理服务接口 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +public interface PlatformDomainService extends IService { + + /** + * 分页查询域名列表 + */ + Page listPage(DomainQueryDTO query); + + /** + * 部署域名到1Panel + * 包含:创建网站、申请证书、配置HTTPS + * + * @param request 部署请求 + * @return 部署结果 + */ + DomainDeployResult deployDomain(DomainDeployRequest request); + + /** + * 检查域名DNS解析状态 + * + * @param domainId 域名ID + * @return 检查结果 + */ + Map checkDomainDns(Long domainId); + + /** + * 从1Panel同步域名信息 + * + * @param domainId 域名ID + * @return 更新后的域名信息 + */ + PlatformDomain syncDomainFromPanel(Long domainId); + + /** + * 获取域名统计信息 + * + * @return 统计信息 + */ + DomainStatsDTO getDomainStats(); + + /** + * 根据域名查找记录 + * + * @param domain 域名 + * @return 域名记录 + */ + PlatformDomain getByDomain(String domain); + + /** + * 从证书中同步域名 + * 将证书表中的域名同步到域名表 + * + * @param serverId 服务器ID + * @return 同步的域名数量 + */ + int syncDomainsFromCertificates(Long serverId); + + /** + * 从1Panel中删除部署(删除网站) + * 保留数据库记录,但清除部署关联信息 + * + * @param domainId 域名ID + */ + void undeployDomain(Long domainId); + + /** + * 部署运行环境到1Panel + * 创建运行时类型的网站 + * + * @param domainId 域名ID + * @return 部署结果 + */ + DomainDeployResult deployRuntime(Long domainId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/PlatformProjectService.java b/src/main/java/com/nanxiislet/admin/service/PlatformProjectService.java new file mode 100644 index 0000000..dfffedf --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/PlatformProjectService.java @@ -0,0 +1,13 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.PlatformProject; + +/** + * 项目管理服务接口 + */ +public interface PlatformProjectService extends IService { + Page listPage(BasePageQuery query); +} diff --git a/src/main/java/com/nanxiislet/admin/service/PlatformServerService.java b/src/main/java/com/nanxiislet/admin/service/PlatformServerService.java new file mode 100644 index 0000000..5acc25c --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/PlatformServerService.java @@ -0,0 +1,39 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.dto.ServerInfoDto; +import com.nanxiislet.admin.entity.PlatformServer; + +/** + * 服务器管理服务接口 + */ +public interface PlatformServerService extends IService { + + /** + * 分页查询服务器列表 + */ + Page listPage(BasePageQuery query); + + /** + * 获取服务器实时状态(从1Panel获取) + */ + ServerInfoDto getServerStatus(Long id); + + /** + * 获取绑定到该服务器的项目名称列表 + */ + java.util.List getBindingProjects(Long serverId); + + /** + * 删除该服务器绑定的所有域名 + */ + void deleteBindingDomains(Long serverId); + + /** + * 删除该服务器绑定的所有证书 + */ + void deleteBindingCertificates(Long serverId); +} + diff --git a/src/main/java/com/nanxiislet/admin/service/SysApprovalInstanceService.java b/src/main/java/com/nanxiislet/admin/service/SysApprovalInstanceService.java new file mode 100644 index 0000000..2b6d546 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysApprovalInstanceService.java @@ -0,0 +1,50 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.dto.approval.ApprovalInstanceVO; +import com.nanxiislet.admin.entity.SysApprovalInstance; + +/** + * 审批实例服务接口 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +public interface SysApprovalInstanceService extends IService { + + /** + * 分页查询实例列表 + */ + IPage listPage(Integer page, Integer pageSize, String scenario, String status, String keyword); + + /** + * 获取实例详情 + */ + ApprovalInstanceVO getDetail(Long id); + + /** + * 创建审批实例 + */ + Long create(String businessType, Long businessId, String businessTitle, Long initiatorId); + + /** + * 提交审批 + */ + void submit(Long id); + + /** + * 审批操作 + */ + void approve(Long id, Long nodeId, Long approverId, String action, String comment); + + /** + * 撤回审批 + */ + void withdraw(Long id); + + /** + * 取消审批 + */ + void cancel(Long id); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysApprovalTemplateService.java b/src/main/java/com/nanxiislet/admin/service/SysApprovalTemplateService.java new file mode 100644 index 0000000..fe5f94e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysApprovalTemplateService.java @@ -0,0 +1,59 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.dto.approval.ApprovalStats; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateRequest; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateVO; +import com.nanxiislet.admin.entity.SysApprovalTemplate; + +import java.util.List; + +/** + * 审批流程模板服务接口 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +public interface SysApprovalTemplateService extends IService { + + /** + * 获取审批统计数据 + */ + ApprovalStats getStats(); + + /** + * 分页查询模板列表 + */ + IPage listPage(Integer page, Integer pageSize, String scenario, Boolean enabled); + + /** + * 获取所有模板(包含节点) + */ + List listAll(String scenario); + + /** + * 获取模板详情(包含节点) + */ + ApprovalTemplateVO getDetail(Long id); + + /** + * 创建模板 + */ + Long create(ApprovalTemplateRequest request); + + /** + * 更新模板 + */ + void update(ApprovalTemplateRequest request); + + /** + * 删除模板 + */ + void delete(Long id); + + /** + * 切换启用状态 + */ + void toggle(Long id); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysDeptService.java b/src/main/java/com/nanxiislet/admin/service/SysDeptService.java new file mode 100644 index 0000000..bc372e8 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysDeptService.java @@ -0,0 +1,45 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.SysDept; + +import java.util.List; + +/** + * 部门服务接口 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +public interface SysDeptService extends IService { + + /** + * 获取部门树形结构 + */ + List listTree(); + + /** + * 获取所有部门列表(扁平结构) + */ + List listAll(); + + /** + * 创建部门 + */ + Long create(SysDept dept); + + /** + * 更新部门 + */ + void updateDept(SysDept dept); + + /** + * 删除部门 + */ + void deleteDept(Long id); + + /** + * 获取部门用户列表 + */ + List getDeptUserIds(Long deptId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysDictItemService.java b/src/main/java/com/nanxiislet/admin/service/SysDictItemService.java new file mode 100644 index 0000000..18a593f --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysDictItemService.java @@ -0,0 +1,45 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.SysDictItem; + +import java.util.List; + +/** + * 字典项 Service + * + * @author NanxiIslet + * @since 2026-01-08 + */ +public interface SysDictItemService extends IService { + + /** + * 根据字典ID获取字典项列表 + */ + List listByDictId(Long dictId); + + /** + * 根据字典编码获取字典项列表 + */ + List listByDictCode(String dictCode); + + /** + * 创建字典项 + */ + Long create(SysDictItem item); + + /** + * 更新字典项 + */ + void updateItem(SysDictItem item); + + /** + * 删除字典项 + */ + void deleteItem(Long id); + + /** + * 根据字典ID删除所有字典项 + */ + void deleteByDictId(Long dictId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysDictService.java b/src/main/java/com/nanxiislet/admin/service/SysDictService.java new file mode 100644 index 0000000..eaeaa27 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysDictService.java @@ -0,0 +1,39 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.SysDict; + +/** + * 字典类型 Service + * + * @author NanxiIslet + * @since 2026-01-08 + */ +public interface SysDictService extends IService { + + /** + * 分页查询字典 + */ + IPage page(Integer page, Integer pageSize, String name, String code); + + /** + * 根据编码获取字典 + */ + SysDict getByCode(String code); + + /** + * 创建字典 + */ + Long create(SysDict dict); + + /** + * 更新字典 + */ + void updateDict(SysDict dict); + + /** + * 删除字典 + */ + void deleteDict(Long id); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysMenuService.java b/src/main/java/com/nanxiislet/admin/service/SysMenuService.java new file mode 100644 index 0000000..530c170 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysMenuService.java @@ -0,0 +1,50 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.SysMenu; + +import java.util.List; + +/** + * 菜单服务接口 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +public interface SysMenuService extends IService { + + /** + * 获取所有菜单(树形结构) + */ + List listTree(); + + /** + * 获取所有菜单(平铺结构) + */ + List listAll(); + + /** + * 创建菜单 + */ + Long create(SysMenu menu); + + /** + * 更新菜单 + */ + void updateMenu(SysMenu menu); + + /** + * 删除菜单 + */ + void deleteMenu(Long id); + + /** + * 根据角色编码获取菜单(树形结构) + */ + List getMenusByRoleCode(String roleCode); + + /** + * 根据角色ID获取菜单 + */ + List getMenusByRoleId(Long roleId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/SysRoleService.java b/src/main/java/com/nanxiislet/admin/service/SysRoleService.java new file mode 100644 index 0000000..ceeff5e --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/SysRoleService.java @@ -0,0 +1,56 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.entity.SysRole; + +import java.util.List; + +/** + * 角色服务接口 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +public interface SysRoleService extends IService { + + /** + * 分页查询角色 + */ + IPage listPage(Integer page, Integer pageSize, String keyword); + + /** + * 获取所有有效角色 + */ + List listAll(); + + /** + * 创建角色 + */ + Long create(SysRole role); + + /** + * 更新角色 + */ + void updateRole(SysRole role); + + /** + * 删除角色 + */ + void deleteRole(Long id); + + /** + * 根据编码查询角色 + */ + SysRole getByCode(String code); + + /** + * 分配菜单权限 + */ + void assignMenus(Long roleId, List menuIds); + + /** + * 获取角色的菜单ID列表 + */ + List getRoleMenuIds(Long roleId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/UserService.java b/src/main/java/com/nanxiislet/admin/service/UserService.java new file mode 100644 index 0000000..fd67fbf --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/UserService.java @@ -0,0 +1,63 @@ +package com.nanxiislet.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.nanxiislet.admin.dto.system.UserQuery; +import com.nanxiislet.admin.entity.SysUser; + +/** + * 用户管理服务接口 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +public interface UserService extends IService { + + /** + * 分页查询用户 + * + * @param query 查询条件 + * @return 分页结果 + */ + Page listPage(UserQuery query); + + /** + * 创建用户 + * + * @param user 用户信息 + */ + void createUser(SysUser user); + + /** + * 更新用户 + * + * @param user 用户信息 + */ + void updateUser(SysUser user); + + /** + * 重置密码 + * + * @param userId 用户ID + * @param newPassword 新密码 + */ + void resetPassword(Long userId, String newPassword); + + /** + * 修改密码 + * + * @param userId 用户ID + * @param oldPassword 旧密码 + * @param newPassword 新密码 + */ + void changePassword(Long userId, String oldPassword, String newPassword); + + /** + * 检查用户名是否存在 + * + * @param username 用户名 + * @param excludeId 排除的用户ID + * @return 是否存在 + */ + boolean isUsernameExists(String username, Long excludeId); +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/AuthServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..b5c7644 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/AuthServiceImpl.java @@ -0,0 +1,223 @@ +package com.nanxiislet.admin.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.IdUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.google.code.kaptcha.Producer; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.auth.*; +import com.nanxiislet.admin.entity.SysMenu; +import com.nanxiislet.admin.entity.SysUser; +import com.nanxiislet.admin.mapper.SysUserMapper; +import com.nanxiislet.admin.service.AuthService; +import com.nanxiislet.admin.service.SysMenuService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 认证服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Slf4j +@Service +public class AuthServiceImpl extends ServiceImpl implements AuthService { + + private static final String CAPTCHA_PREFIX = "captcha:"; + private static final long CAPTCHA_EXPIRE_SECONDS = 300L; + + @Resource + private Producer captchaProducer; + + @Resource + private RedisTemplate redisTemplate; + + @Resource + private SysMenuService menuService; + + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Override + public CaptchaResponse generateCaptcha() { + String captchaKey = IdUtil.fastSimpleUUID(); + String captchaText = captchaProducer.createText(); + + // 存入Redis + String redisKey = CAPTCHA_PREFIX + captchaKey; + redisTemplate.opsForValue().set(redisKey, captchaText, CAPTCHA_EXPIRE_SECONDS, TimeUnit.SECONDS); + + // 生成图片 + BufferedImage image = captchaProducer.createImage(captchaText); + String base64Image; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", baos); + base64Image = "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (Exception e) { + log.error("生成验证码图片失败", e); + throw new BusinessException("生成验证码失败"); + } + + log.debug("生成验证码: key={}, text={}", captchaKey, captchaText); + + return CaptchaResponse.builder() + .captchaKey(captchaKey) + .captchaImage(base64Image) + .build(); + } + + @Override + public LoginResponse login(LoginRequest request) { + // 验证验证码 + String redisKey = CAPTCHA_PREFIX + request.getCaptchaKey(); + Object cachedCaptcha = redisTemplate.opsForValue().get(redisKey); + if (cachedCaptcha == null) { + throw new BusinessException(ResultCode.CAPTCHA_EXPIRED); + } + if (!cachedCaptcha.toString().equalsIgnoreCase(request.getCaptcha())) { + throw new BusinessException(ResultCode.CAPTCHA_ERROR); + } + // 删除已使用的验证码 + redisTemplate.delete(redisKey); + + // 查询用户 + SysUser user = getByUsername(request.getUsername()); + if (user == null) { + throw new BusinessException(ResultCode.USER_PASSWORD_ERROR); + } + + // 验证密码 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new BusinessException(ResultCode.USER_PASSWORD_ERROR); + } + + // 检查用户状态 + if (user.getStatus() != 1) { + throw new BusinessException(ResultCode.USER_DISABLED); + } + + // 更新登录信息 + user.setLastLoginTime(LocalDateTime.now()); + updateById(user); + + // Sa-Token 登录 + StpUtil.login(user.getId()); + String token = StpUtil.getTokenValue(); + + // 构建用户信息 + UserInfoVO userInfo = buildUserInfoVO(user); + + log.info("用户登录成功: userId={}, username={}", user.getId(), user.getUsername()); + + return LoginResponse.builder() + .token(token) + .expires(StpUtil.getTokenTimeout()) + .userInfo(userInfo) + .build(); + } + + @Override + public void logout() { + if (StpUtil.isLogin()) { + log.info("用户登出: userId={}", StpUtil.getLoginId()); + StpUtil.logout(); + } + } + + @Override + public UserInfoVO getCurrentUser() { + if (!StpUtil.isLogin()) { + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + Long userId = Long.parseLong(StpUtil.getLoginId().toString()); + SysUser user = getById(userId); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + return buildUserInfoVO(user); + } + + @Override + public SysUser getByUsername(String username) { + return getOne(new LambdaQueryWrapper() + .eq(SysUser::getUsername, username)); + } + + /** + * 构建用户信息VO + */ + private UserInfoVO buildUserInfoVO(SysUser user) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // 获取用户角色对应的菜单权限 + List permissions = getPermissionsByRole(user.getRole()); + + return UserInfoVO.builder() + .id(user.getId()) + .username(user.getUsername()) + .nickname(user.getNickname()) + .avatar(user.getAvatar()) + .email(user.getEmail()) + .phone(user.getPhone()) + .role(user.getRole()) + .permissions(permissions) + .menus(getMenusByRole(user.getRole())) + .createTime(user.getCreatedAt() != null ? user.getCreatedAt().format(formatter) : null) + .lastLoginTime(user.getLastLoginTime() != null ? user.getLastLoginTime().format(formatter) : null) + .build(); + } + + /** + * 根据角色获取权限列表 + */ + private List getPermissionsByRole(String roleCode) { + // 超级管理员返回所有权限 + if ("super_admin".equals(roleCode)) { + return List.of("*"); + } + + // 根据角色编码获取菜单 + List menus = menuService.getMenusByRoleCode(roleCode); + if (menus == null || menus.isEmpty()) { + return List.of(); + } + + // 返回菜单编码列表作为权限 + return menus.stream() + .map(SysMenu::getCode) + .distinct() + .toList(); + } + + /** + * 根据角色获取菜单列表 + */ + private List getMenusByRole(String roleCode) { + // 超级管理员返回所有菜单 + if ("super_admin".equals(roleCode)) { + // 获取所有启用状态的菜单 + return menuService.list(new LambdaQueryWrapper() + .eq(SysMenu::getStatus, 1) + .orderByAsc(SysMenu::getSort)); + } + + // 其他角色根据关联查询 + return menuService.getMenusByRoleCode(roleCode); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceAccountServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceAccountServiceImpl.java new file mode 100644 index 0000000..d12a459 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceAccountServiceImpl.java @@ -0,0 +1,32 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.entity.FinanceAccount; +import com.nanxiislet.admin.mapper.FinanceAccountMapper; +import com.nanxiislet.admin.service.FinanceAccountService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 财务账户服务实现 + */ +@Service +public class FinanceAccountServiceImpl extends ServiceImpl implements FinanceAccountService { + + @Override + public List listActive() { + return list(new LambdaQueryWrapper() + .eq(FinanceAccount::getStatus, "active") + .orderByDesc(FinanceAccount::getIsDefault) + .orderByAsc(FinanceAccount::getCreatedAt)); + } + + @Override + public FinanceAccount getDefault() { + return getOne(new LambdaQueryWrapper() + .eq(FinanceAccount::getIsDefault, true) + .eq(FinanceAccount::getStatus, "active")); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceBudgetServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceBudgetServiceImpl.java new file mode 100644 index 0000000..aa63ed0 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceBudgetServiceImpl.java @@ -0,0 +1,108 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceBudget; +import com.nanxiislet.admin.mapper.FinanceBudgetMapper; +import com.nanxiislet.admin.service.FinanceBudgetService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 预算管理服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Service +public class FinanceBudgetServiceImpl extends ServiceImpl implements FinanceBudgetService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 关键词搜索 + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceBudget::getName, query.getKeyword()) + .or().like(FinanceBudget::getDepartmentName, query.getKeyword()) + .or().like(FinanceBudget::getProjectName, query.getKeyword()) + ); + } + + // 默认按年份、创建时间倒序 + wrapper.orderByDesc(FinanceBudget::getYear) + .orderByDesc(FinanceBudget::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public FinanceBudget findBudget(Integer year, String period, Integer quarter, Integer month, Long departmentId, Long projectId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FinanceBudget::getYear, year) + .eq(FinanceBudget::getPeriod, period); + + if (quarter != null) { + wrapper.eq(FinanceBudget::getQuarter, quarter); + } + if (month != null) { + wrapper.eq(FinanceBudget::getMonth, month); + } + if (departmentId != null) { + wrapper.eq(FinanceBudget::getDepartmentId, departmentId); + } + if (projectId != null) { + wrapper.eq(FinanceBudget::getProjectId, projectId); + } + + return getOne(wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateUsedAmount(Long budgetId, BigDecimal usedAmount) { + FinanceBudget budget = getById(budgetId); + if (budget == null) { + return; + } + + BigDecimal newUsedAmount = budget.getUsedAmount().add(usedAmount); + BigDecimal remainingAmount = budget.getTotalBudget().subtract(newUsedAmount); + + FinanceBudget update = new FinanceBudget(); + update.setId(budgetId); + update.setUsedAmount(newUsedAmount); + update.setRemainingAmount(remainingAmount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : remainingAmount); + + updateById(update); + calculateUsageRate(budgetId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void calculateUsageRate(Long budgetId) { + FinanceBudget budget = getById(budgetId); + if (budget == null || budget.getTotalBudget().compareTo(BigDecimal.ZERO) == 0) { + return; + } + + BigDecimal usageRate = budget.getUsedAmount() + .divide(budget.getTotalBudget(), 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(2, RoundingMode.HALF_UP); + + FinanceBudget update = new FinanceBudget(); + update.setId(budgetId); + update.setUsageRate(usageRate); + updateById(update); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceExpenseServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceExpenseServiceImpl.java new file mode 100644 index 0000000..84b46d4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceExpenseServiceImpl.java @@ -0,0 +1,48 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceExpense; +import com.nanxiislet.admin.mapper.FinanceExpenseMapper; +import com.nanxiislet.admin.service.FinanceExpenseService; +import org.springframework.stereotype.Service; + +import java.util.Date; + +/** + * 支出管理服务实现 + */ +@Service +public class FinanceExpenseServiceImpl extends ServiceImpl implements FinanceExpenseService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceExpense::getExpenseNo, query.getKeyword()) + .or().like(FinanceExpense::getTitle, query.getKeyword()) + .or().like(FinanceExpense::getPayeeName, query.getKeyword()) + ); + } + + wrapper.orderByDesc(FinanceExpense::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public String generateExpenseNo() { + String prefix = "EX" + DateUtil.format(new Date(), "yyyyMMdd"); + long count = count(new LambdaQueryWrapper() + .likeRight(FinanceExpense::getExpenseNo, prefix)); + return prefix + String.format("%04d", count + 1); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceIncomeServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceIncomeServiceImpl.java new file mode 100644 index 0000000..db4d886 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceIncomeServiceImpl.java @@ -0,0 +1,53 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceIncome; +import com.nanxiislet.admin.mapper.FinanceIncomeMapper; +import com.nanxiislet.admin.service.FinanceIncomeService; +import org.springframework.stereotype.Service; + +import java.util.Date; + +/** + * 收入管理服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Service +public class FinanceIncomeServiceImpl extends ServiceImpl implements FinanceIncomeService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 关键词搜索 + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceIncome::getIncomeNo, query.getKeyword()) + .or().like(FinanceIncome::getTitle, query.getKeyword()) + .or().like(FinanceIncome::getCustomerName, query.getKeyword()) + ); + } + + // 默认按创建时间倒序 + wrapper.orderByDesc(FinanceIncome::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public String generateIncomeNo() { + String prefix = "IN" + DateUtil.format(new Date(), "yyyyMMdd"); + long count = count(new LambdaQueryWrapper() + .likeRight(FinanceIncome::getIncomeNo, prefix)); + return prefix + String.format("%04d", count + 1); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceInvoiceServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceInvoiceServiceImpl.java new file mode 100644 index 0000000..b122d56 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceInvoiceServiceImpl.java @@ -0,0 +1,81 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceInvoice; +import com.nanxiislet.admin.mapper.FinanceInvoiceMapper; +import com.nanxiislet.admin.service.FinanceInvoiceService; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +/** + * 发票管理服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Service +public class FinanceInvoiceServiceImpl extends ServiceImpl implements FinanceInvoiceService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 关键词搜索 + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceInvoice::getTitle, query.getKeyword()) + .or().like(FinanceInvoice::getTaxCode, query.getKeyword()) + ); + } + + // 默认按创建时间倒序 + wrapper.orderByDesc(FinanceInvoice::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public void issueInvoice(Long id) { + FinanceInvoice invoice = getById(id); + if (invoice == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + if (!"pending".equals(invoice.getStatus())) { + throw new BusinessException("只有待开票状态的发票才能开具"); + } + + FinanceInvoice update = new FinanceInvoice(); + update.setId(id); + update.setStatus("issued"); + update.setIssueTime(LocalDate.now()); + updateById(update); + } + + @Override + public void rejectInvoice(Long id, String reason) { + FinanceInvoice invoice = getById(id); + if (invoice == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + if (!"pending".equals(invoice.getStatus())) { + throw new BusinessException("只有待开票状态的发票才能驳回"); + } + + FinanceInvoice update = new FinanceInvoice(); + update.setId(id); + update.setStatus("rejected"); + update.setRejectReason(reason); + updateById(update); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceReimbursementServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceReimbursementServiceImpl.java new file mode 100644 index 0000000..9084e04 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceReimbursementServiceImpl.java @@ -0,0 +1,48 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.FinanceReimbursement; +import com.nanxiislet.admin.mapper.FinanceReimbursementMapper; +import com.nanxiislet.admin.service.FinanceReimbursementService; +import org.springframework.stereotype.Service; + +import java.util.Date; + +/** + * 报销管理服务实现 + */ +@Service +public class FinanceReimbursementServiceImpl extends ServiceImpl implements FinanceReimbursementService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceReimbursement::getReimbursementNo, query.getKeyword()) + .or().like(FinanceReimbursement::getTitle, query.getKeyword()) + .or().like(FinanceReimbursement::getApplicantName, query.getKeyword()) + ); + } + + wrapper.orderByDesc(FinanceReimbursement::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public String generateReimbursementNo() { + String prefix = "RB" + DateUtil.format(new Date(), "yyyyMMdd"); + long count = count(new LambdaQueryWrapper() + .likeRight(FinanceReimbursement::getReimbursementNo, prefix)); + return prefix + String.format("%04d", count + 1); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/FinanceSettlementServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/FinanceSettlementServiceImpl.java new file mode 100644 index 0000000..acaadf8 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/FinanceSettlementServiceImpl.java @@ -0,0 +1,131 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.entity.FinanceSettlement; +import com.nanxiislet.admin.mapper.FinanceSettlementMapper; +import com.nanxiislet.admin.service.FinanceSettlementService; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; + +/** + * 结算管理服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Service +public class FinanceSettlementServiceImpl extends ServiceImpl implements FinanceSettlementService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 关键词搜索 + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(FinanceSettlement::getProjectName, query.getKeyword()) + .or().like(FinanceSettlement::getTalentName, query.getKeyword()) + .or().like(FinanceSettlement::getPeriod, query.getKeyword()) + ); + } + + // 默认按创建时间倒序 + wrapper.orderByDesc(FinanceSettlement::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public void approve(Long id) { + FinanceSettlement settlement = getById(id); + if (settlement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + if (!"pending".equals(settlement.getStatus())) { + throw new BusinessException("只有待审核状态的结算单才能审核"); + } + + FinanceSettlement update = new FinanceSettlement(); + update.setId(id); + update.setStatus("paying"); + update.setAuditTime(LocalDate.now()); + updateById(update); + } + + @Override + public void reject(Long id, String reason) { + FinanceSettlement settlement = getById(id); + if (settlement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + if (!"pending".equals(settlement.getStatus())) { + throw new BusinessException("只有待审核状态的结算单才能驳回"); + } + + FinanceSettlement update = new FinanceSettlement(); + update.setId(id); + update.setStatus("rejected"); + update.setRemark(reason); + updateById(update); + } + + @Override + public void confirmPayment(Long id) { + FinanceSettlement settlement = getById(id); + if (settlement == null) { + throw new BusinessException(ResultCode.DATA_NOT_EXIST); + } + + if (!"paying".equals(settlement.getStatus())) { + throw new BusinessException("只有待打款状态的结算单才能确认打款"); + } + + FinanceSettlement update = new FinanceSettlement(); + update.setId(id); + update.setStatus("completed"); + update.setPaymentTime(LocalDate.now()); + updateById(update); + } + + @Override + public void calculateAmounts(FinanceSettlement settlement) { + BigDecimal totalAmount = settlement.getTotalAmount(); + if (totalAmount == null) { + totalAmount = BigDecimal.ZERO; + } + + BigDecimal platformFee = settlement.getPlatformFee(); + if (platformFee == null) { + platformFee = BigDecimal.ZERO; + } + + // 应纳税所得额 = 总额 - 平台服务费 + BigDecimal taxableAmount = totalAmount.subtract(platformFee); + settlement.setTaxableAmount(taxableAmount); + + // 计算税金 + BigDecimal taxRate = settlement.getTaxRate(); + if (taxRate == null) { + taxRate = new BigDecimal("0.03"); // 默认3% + } + BigDecimal taxAmount = taxableAmount.multiply(taxRate).setScale(2, RoundingMode.HALF_UP); + settlement.setTaxAmount(taxAmount); + + // 实发金额 = 应纳税所得额 - 税金 + BigDecimal actualAmount = taxableAmount.subtract(taxAmount); + settlement.setActualAmount(actualAmount); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/OnePanelServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/OnePanelServiceImpl.java new file mode 100644 index 0000000..2e8f889 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/OnePanelServiceImpl.java @@ -0,0 +1,2336 @@ +package com.nanxiislet.admin.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.nanxiislet.admin.dto.DeployRequest; +import com.nanxiislet.admin.dto.DeployResult; +import com.nanxiislet.admin.entity.PlatformProject; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.entity.PlatformCertificate; +import com.nanxiislet.admin.entity.PlatformDomain; +import com.nanxiislet.admin.service.OnePanelService; +import com.nanxiislet.admin.service.PlatformCertificateService; +import com.nanxiislet.admin.service.PlatformDomainService; +import com.nanxiislet.admin.service.PlatformProjectService; +import com.nanxiislet.admin.service.PlatformServerService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.client.SimpleClientHttpRequestFactory; + +/** + * 1Panel服务实现 + * + * @author NanxiIslet + * @since 2024-01-10 + */ +@Slf4j +@Service +public class OnePanelServiceImpl implements OnePanelService { + + @Resource + private PlatformProjectService projectService; + + @Resource + private PlatformDomainService domainService; + + @Resource + private PlatformCertificateService certificateService; + + @Resource + private PlatformServerService serverService; + + @Resource + private ObjectMapper objectMapper; + + // 普通 API 请求的 RestTemplate + private final RestTemplate restTemplate; + + // 专门用于文件上传的 RestTemplate,具有更长的超时时间 + private final RestTemplate uploadRestTemplate; + + public OnePanelServiceImpl() { + // 普通 API 请求配置:30秒连接超时,2分钟读取超时 + SimpleClientHttpRequestFactory normalFactory = new SimpleClientHttpRequestFactory(); + normalFactory.setConnectTimeout(30 * 1000); // 30秒 + normalFactory.setReadTimeout(2 * 60 * 1000); // 2分钟 + this.restTemplate = new RestTemplate(normalFactory); + + // 文件上传专用配置:5分钟连接超时,30分钟读取超时 + SimpleClientHttpRequestFactory uploadFactory = new SimpleClientHttpRequestFactory(); + uploadFactory.setConnectTimeout(5 * 60 * 1000); // 5分钟 + uploadFactory.setReadTimeout(30 * 60 * 1000); // 30分钟 + this.uploadRestTemplate = new RestTemplate(uploadFactory); + } + + /** + * 生成1Panel Token + * Token = md5('1panel' + API-Key + UnixTimestamp) + */ + private String generateToken(String apiKey, long timestamp) { + String rawToken = "1panel" + apiKey + timestamp; + return DigestUtils.md5DigestAsHex(rawToken.getBytes()); + } + + /** + * 创建HTTP请求头 + */ + private HttpHeaders createHeaders(PlatformServer server) { + long timestamp = System.currentTimeMillis() / 1000; + String token = generateToken(server.getPanelApiKey(), timestamp); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("1Panel-Token", token); + headers.set("1Panel-Timestamp", String.valueOf(timestamp)); + return headers; + } + + /** + * 获取1Panel API基础URL + */ + private String getBaseUrl(PlatformServer server) { + if (StringUtils.hasText(server.getPanelUrl())) { + String baseUrl = server.getPanelUrl(); + // 尝试去除URL中的路径部分(例如安全入口 /super),只保留 协议://IP:端口 + try { + java.net.URI uri = new java.net.URI(baseUrl); + String scheme = uri.getScheme(); + String host = uri.getHost(); + int port = uri.getPort(); + + if (scheme != null && host != null) { + StringBuilder cleanUrl = new StringBuilder(scheme).append("://").append(host); + if (port != -1) { + cleanUrl.append(":").append(port); + } + return cleanUrl.toString(); + } + } catch (Exception e) { + log.warn("解析Panel URL失败: {}", baseUrl, e); + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + return baseUrl; + } + String protocol = "http"; + String ip = server.getIp(); + Integer port = server.getPanelPort() != null ? server.getPanelPort() : 42588; + return protocol + "://" + ip + ":" + port; + } + + /** + * 发送GET请求到1Panel + */ + private String sendGet(PlatformServer server, String path) { + String url = getBaseUrl(server) + path; + // log.info("1Panel GET Request: {}", url); + HttpHeaders headers = createHeaders(server); + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + return response.getBody(); + } catch (Exception e) { + log.error("1Panel GET请求失败: {}", url, e); + throw new RuntimeException("1Panel API请求失败: " + e.getMessage()); + } + } + + /** + * 发送POST请求到1Panel + */ + private String sendPost(PlatformServer server, String path, Object body) { + String url = getBaseUrl(server) + path; + log.info("1Panel POST Request: {}", url); // 添加日志 + HttpHeaders headers = createHeaders(server); + + try { + String jsonBody = body != null ? objectMapper.writeValueAsString(body) : "{}"; + log.info("1Panel POST Body: {}", jsonBody); // 打印请求体 + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } catch (Exception e) { + log.error("1Panel POST请求失败: {}", url, e); + // 尝试获取更多错误信息,比如是不是返回了HTML + if (e.getMessage() != null && e.getMessage().contains("<")) { + log.error("1Panel API返回了非JSON格式数据。可能是URL错误(包含安全入口?)或认证失败。"); + } + throw new RuntimeException("1Panel API请求失败: " + e.getMessage()); + } + } + + @Override + public DeployResult deployProject(DeployRequest request) { + DeployResult result = DeployResult.success("部署流程开始"); + + try { + // 1. 获取项目信息 + PlatformProject project = projectService.getById(request.getProjectId()); + if (project == null) { + return DeployResult.failed("项目不存在"); + } + + // 2. 检查项目是否绑定域名 + if (!StringUtils.hasText(project.getDomain())) { + return DeployResult.failed("项目未绑定域名,无法部署"); + } + + // 3. 获取服务器信息 + PlatformServer server = serverService.getById(project.getServerId()); + if (server == null) { + return DeployResult.failed("项目未绑定服务器"); + } + + // 4. 检查服务器1Panel配置 + if (!StringUtils.hasText(server.getPanelApiKey())) { + return DeployResult.failed("服务器未配置1Panel API密钥"); + } + + // 更新项目状态为部署中 + project.setStatus("deploying"); + project.setLastDeployTime(LocalDateTime.now()); + project.setLastDeployStatus("deploying"); + projectService.updateById(project); + + // 5. 检查/创建网站 + Long websiteId = checkOrCreateWebsite(server, project, request, result); + if (websiteId == null && !result.getSuccess()) { + updateProjectDeployStatus(project, "failed", result.getMessage()); + return result; + } + result.setWebsiteId(websiteId); + + // 6. 如果启用HTTPS,检查/申请证书 + if (Boolean.TRUE.equals(request.getEnableHttps())) { + Long sslId = checkOrApplyCertificate(server, project, request, result); + result.setSslCertificateId(sslId); + + // 7. 配置HTTPS + if (sslId != null && websiteId != null) { + configureWebsiteHttps(server, websiteId, sslId, result); + } + } else { + result.addSkippedStep("配置HTTPS", "未启用HTTPS"); + } + + // 更新项目状态 + project.setPanelWebsiteId(websiteId); + project.setPanelSslId(result.getSslCertificateId()); + project.setEnableHttps(request.getEnableHttps()); + + result.setSuccess(true); + result.setMessage("部署流程完成"); + updateProjectDeployStatus(project, "success", "部署成功"); + + // 更新对应域名的状态 + if (project.getDomainId() != null) { + updateDomainDeployStatus(project.getDomainId(), websiteId, result.getSslCertificateId(), project, true); + } + + return result; + + } catch (Exception e) { + log.error("部署项目失败", e); + result.setSuccess(false); + result.setMessage("部署失败: " + e.getMessage()); + result.addFailedStep("异常", e.getMessage()); + return result; + } + } + + /** + * 更新项目部署状态 + */ + private void updateProjectDeployStatus(PlatformProject project, String status, String message) { + project.setLastDeployStatus(status); + project.setLastDeployMessage(message); + project.setStatus("success".equals(status) ? "active" : "inactive"); + projectService.updateById(project); + } + + /** + * 更新域名部署状态 + */ + private void updateDomainDeployStatus(Long domainId, Long websiteId, Long sslId, PlatformProject project, boolean deployed) { + try { + PlatformDomain domain = domainService.getById(domainId); + if (domain != null) { + // 更新网站信息 + domain.setPanelWebsiteId(websiteId); + domain.setPanelSslId(sslId); + domain.setDeployStatus(deployed ? "deployed" : "failed"); + domain.setStatus(deployed ? "active" : "pending"); + domain.setLastDeployTime(java.time.LocalDateTime.now()); + domain.setLastDeployMessage(deployed ? "部署成功" : "部署失败"); + + // 更新项目关联信息 + if (project != null) { + domain.setProjectId(project.getId()); + domain.setProjectName(project.getName()); + } + + // 如果有 SSL,更新 SSL 状态 + if (sslId != null) { + domain.setSslStatus("valid"); + domain.setEnableHttps(true); + } + + domainService.updateById(domain); + log.info("域名状态已更新: domainId={}, websiteId={}, projectId={}, deployed={}", + domainId, websiteId, project != null ? project.getId() : null, deployed); + + // 部署成功后,自动检测 DNS 解析状态 + if (deployed) { + try { + domainService.checkDomainDns(domainId); + log.info("DNS检测完成: domainId={}", domainId); + } catch (Exception e) { + log.warn("DNS检测失败: domainId={}", domainId, e); + } + } + } + } catch (Exception e) { + log.error("更新域名状态失败: domainId={}", domainId, e); + } + } + + /** + * 检查或创建网站 + */ + private Long checkOrCreateWebsite(PlatformServer server, PlatformProject project, + DeployRequest request, DeployResult result) { + try { + // 先查找是否已存在该域名的网站 + Map searchParams = new HashMap<>(); + searchParams.put("page", 1); + searchParams.put("pageSize", 100); + searchParams.put("name", project.getDomain()); + searchParams.put("orderBy", "created_at"); + searchParams.put("order", "descending"); + String response = sendPost(server, "/api/v2/websites/search", searchParams); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + String domainName = project.getDomain(); + String expectedAlias = domainName; // 1Panel 实际返回的 alias 就是域名本身 + + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimaryDomain = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (expectedAlias.equals(itemAlias) || domainName.equals(itemPrimaryDomain)) { + Long websiteId = item.get("id").asLong(); + result.addSuccessStep("检查网站", "网站已存在,ID: " + websiteId); + return websiteId; + } + } + } + + // 网站不存在,需要创建 + if (!Boolean.TRUE.equals(request.getCreateIfNotExist())) { + result.setSuccess(false); + result.setMessage("网站不存在,且未设置自动创建"); + result.addFailedStep("检查网站", "网站不存在"); + return null; + } + + // 创建网站 - 按照 1Panel 实际接口格式 + Map createParams = new HashMap<>(); + String alias = domainName; // 1Panel 实际使用域名作为 alias + + // 基础参数 + createParams.put("primaryDomain", ""); // 1Panel 实际请求中为空 + createParams.put("alias", alias); + createParams.put("type", "static"); + createParams.put("appType", "installed"); // 静态网站使用 installed + createParams.put("webSiteGroupId", 1); // 注意大小写 + createParams.put("remark", "项目: " + project.getName()); + createParams.put("otherDomains", ""); + createParams.put("proxy", ""); + createParams.put("IPV6", false); + createParams.put("enableFtp", false); + createParams.put("proxyType", "tcp"); + createParams.put("port", 80); + createParams.put("proxyProtocol", "http://"); + createParams.put("proxyAddress", ""); + createParams.put("runtimeType", "php"); + createParams.put("createDb", false); + createParams.put("dbType", "mysql"); + createParams.put("dbFormat", "utf8mb4"); + + // domains 数组 + List> domains = new ArrayList<>(); + Map domainItem = new HashMap<>(); + domainItem.put("domain", domainName); + domainItem.put("port", 80); + domainItem.put("ssl", false); + domains.add(domainItem); + createParams.put("domains", domains); + + // siteDir 留空,让 1Panel 自动处理 + createParams.put("siteDir", ""); + + // SSL 配置 + Long sslIdToConfig = null; + if (project.getDomainId() != null) { + PlatformDomain domain = domainService.getById(project.getDomainId()); + if (domain != null && domain.getCertificateId() != null) { + PlatformCertificate cert = certificateService.getById(domain.getCertificateId()); + if (cert != null && cert.getPanelSslId() != null) { + sslIdToConfig = cert.getPanelSslId(); + // 在创建时启用 SSL + createParams.put("enableSSL", true); + createParams.put("websiteSSLID", cert.getPanelSslId()); + result.addSuccessStep("SSL配置", "将绑定证书: " + cert.getPrimaryDomain()); + } + } + } + if (sslIdToConfig == null) { + createParams.put("enableSSL", false); + } + + // 先调用 check 接口进行预检 + try { + Map checkParams = new HashMap<>(); + checkParams.put("primaryDomain", project.getDomain()); + checkParams.put("type", "static"); + String checkResponse = sendPost(server, "/api/v2/websites/check", checkParams); + log.info("1Panel Check Response: {}", checkResponse); + JsonNode checkResult = objectMapper.readTree(checkResponse); + if (checkResult.has("code") && checkResult.get("code").asInt() != 200) { + String errMsg = checkResult.has("message") ? checkResult.get("message").asText() : "预检失败"; + result.addFailedStep("预检网站", errMsg); + result.setSuccess(false); + result.setMessage("网站预检失败: " + errMsg); + return null; + } + result.addSuccessStep("预检网站", "域名可用"); + } catch (Exception checkEx) { + log.warn("网站预检接口调用失败,继续尝试创建: {}", checkEx.getMessage()); + } + + // 调用创建接口 + String createResponse = sendPost(server, "/api/v2/websites", createParams); + log.info("1Panel Create Response: {}", createResponse); + JsonNode createResult = objectMapper.readTree(createResponse); + + // 检查创建结果 + if (createResult.has("code") && createResult.get("code").asInt() == 200) { + // 重新查询获取网站ID + Map searchParams2 = new HashMap<>(); + searchParams2.put("page", 1); + searchParams2.put("pageSize", 10); + searchParams2.put("name", alias); + searchParams2.put("orderBy", "created_at"); + searchParams2.put("order", "descending"); + response = sendPost(server, "/api/v2/websites/search", searchParams2); + log.info("1Panel Search Response: {}", response); + jsonNode = objectMapper.readTree(response); + data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + // 用 alias 匹配而不是 primaryDomain + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimaryDomain = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (alias.equals(itemAlias) || domainName.equals(itemPrimaryDomain)) { + Long websiteId = item.get("id").asLong(); + result.addSuccessStep("创建网站", "网站创建成功,ID: " + websiteId); + + // 如果有证书,配置 HTTPS + if (sslIdToConfig != null) { + try { + configureWebsiteHttps(server, websiteId, sslIdToConfig, result); + } catch (Exception e) { + log.warn("配置HTTPS失败,但网站已创建成功: {}", e.getMessage()); + } + } + + return websiteId; + } + } + } + } else { + // 记录详细的错误信息 + String errMsg = createResult.has("message") ? createResult.get("message").asText() : "未知错误"; + log.error("1Panel 创建网站失败: code={}, message={}", + createResult.has("code") ? createResult.get("code").asInt() : "null", errMsg); + result.addFailedStep("创建网站", errMsg); + result.setSuccess(false); + result.setMessage("网站创建失败: " + errMsg); + return null; + } + + result.addFailedStep("创建网站", "网站创建失败"); + result.setSuccess(false); + result.setMessage("网站创建失败"); + return null; + + } catch (Exception e) { + log.error("检查/创建网站失败", e); + result.addFailedStep("检查网站", e.getMessage()); + result.setSuccess(false); + result.setMessage("检查网站失败: " + e.getMessage()); + return null; + } + } + + /** + * 检查或申请证书 + */ + private Long checkOrApplyCertificate(PlatformServer server, PlatformProject project, + DeployRequest request, DeployResult result) { + try { + // 先查找是否已存在该域名的证书 + String response = sendPost(server, "/api/v2/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + String projectDomain = project.getDomain(); + log.info("正在查找证书,项目域名: {}", projectDomain); + + for (JsonNode item : items) { + String primaryDomain = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + Long sslId = item.get("id").asLong(); + log.info("检查证书: primaryDomain={}, sslId={}", primaryDomain, sslId); + + // 1. 精确匹配 + if (projectDomain.equals(primaryDomain)) { + log.info("证书精确匹配成功: {} = {}, sslId={}", projectDomain, primaryDomain, sslId); + result.addSuccessStep("检查SSL证书", "证书已存在(精确匹配),ID: " + sslId); + return sslId; + } + + // 2. 通配符证书匹配 (*.example.com 匹配 test.example.com) + if (primaryDomain.startsWith("*.")) { + String wildcardBase = primaryDomain.substring(2); // example.com + if (projectDomain.endsWith("." + wildcardBase)) { + log.info("证书通配符匹配成功: {} matches {}, sslId={}", projectDomain, primaryDomain, sslId); + result.addSuccessStep("检查SSL证书", "证书已存在(通配符匹配 " + primaryDomain + "),ID: " + sslId); + return sslId; + } + } + } + log.warn("未找到匹配的证书,项目域名: {}", projectDomain); + } + + // 证书不存在,需要申请 + if (request.getAcmeAccountId() == null || request.getDnsAccountId() == null) { + result.addSkippedStep("申请SSL证书", "未配置Acme/DNS账户"); + return null; + } + + // 申请证书 + Map applyParams = new HashMap<>(); + applyParams.put("primaryDomain", project.getDomain()); + applyParams.put("otherDomains", ""); + applyParams.put("provider", "dnsAccount"); + applyParams.put("acmeAccountId", request.getAcmeAccountId()); + applyParams.put("dnsAccountId", request.getDnsAccountId()); + applyParams.put("autoRenew", true); + applyParams.put("keyType", "P256"); + applyParams.put("apply", true); + + sendPost(server, "/api/v2/websites/ssl", applyParams); + + // 等待证书申请完成(最多等待120秒) + for (int i = 0; i < 24; i++) { + Thread.sleep(5000); + + response = sendPost(server, "/api/v2/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + jsonNode = objectMapper.readTree(response); + data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String primaryDomain = item.get("primaryDomain").asText(); + if (project.getDomain().equals(primaryDomain)) { + Long sslId = item.get("id").asLong(); + result.addSuccessStep("申请SSL证书", "证书申请成功,ID: " + sslId); + return sslId; + } + } + } + } + + result.addFailedStep("申请SSL证书", "证书申请超时"); + return null; + + } catch (Exception e) { + log.error("检查/申请证书失败", e); + result.addFailedStep("检查SSL证书", e.getMessage()); + return null; + } + } + + /** + * 配置网站HTTPS + */ + private void configureWebsiteHttps(PlatformServer server, Long websiteId, Long sslId, DeployResult result) { + try { + log.info("开始配置HTTPS: websiteId={}, sslId={}", websiteId, sslId); + + Map httpsParams = new HashMap<>(); + httpsParams.put("websiteId", websiteId); // 1Panel 需要在请求体中包含 websiteId + httpsParams.put("enable", true); + httpsParams.put("type", "existed"); // existed=使用已有证书, select 不是有效值 + httpsParams.put("websiteSSLId", sslId); + httpsParams.put("httpConfig", "HTTPToHTTPS"); + httpsParams.put("SSLProtocol", new String[]{"TLSv1.2", "TLSv1.3"}); + httpsParams.put("algorithm", ""); + + String response = sendPost(server, "/api/v2/websites/" + websiteId + "/https", httpsParams); + log.info("配置HTTPS API响应: {}", response); + + // 检查响应结果 + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.addSuccessStep("配置HTTPS", "HTTPS配置成功"); + log.info("HTTPS配置成功: websiteId={}, sslId={}", websiteId, sslId); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "未知错误"; + result.addFailedStep("配置HTTPS", errMsg); + log.error("HTTPS配置失败: websiteId={}, sslId={}, error={}", websiteId, sslId, errMsg); + } + + } catch (Exception e) { + log.error("配置HTTPS失败", e); + result.addFailedStep("配置HTTPS", e.getMessage()); + } + } + + @Override + public String getWebsites(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + throw new RuntimeException("服务器不存在"); + } + return sendPost(server, "/websites/search", Map.of( + "page", 1, + "pageSize", 100 + )); + } + + @Override + public String getCertificates(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + throw new RuntimeException("服务器不存在"); + } + return sendPost(server, "/api/v2/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + } + + @Override + public Long checkWebsiteExists(Long serverId, String domain) { + if (domain == null || domain.isEmpty()) { + return null; + } + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return null; + } + + try { + Map searchParams = new HashMap<>(); + searchParams.put("page", 1); + searchParams.put("pageSize", 100); + searchParams.put("name", domain); + searchParams.put("orderBy", "created_at"); + searchParams.put("order", "descending"); + + String response = sendPost(server, "/api/v2/websites/search", searchParams); + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimaryDomain = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (domain.equals(itemAlias) || domain.equals(itemPrimaryDomain)) { + return item.get("id").asLong(); + } + } + } + } catch (Exception e) { + log.error("检查网站是否存在失败: {}", e.getMessage()); + } + + return null; + } + + @Override + public Map checkWebsiteStatus(Long serverId, String domain) { + Map result = new HashMap<>(); + result.put("exists", false); + + if (domain == null || domain.isEmpty()) { + return result; + } + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return result; + } + + try { + Map searchParams = new HashMap<>(); + searchParams.put("page", 1); + searchParams.put("pageSize", 100); + searchParams.put("name", domain); + searchParams.put("orderBy", "created_at"); + searchParams.put("order", "descending"); + + String response = sendPost(server, "/api/v2/websites/search", searchParams); + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimaryDomain = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (domain.equals(itemAlias) || domain.equals(itemPrimaryDomain)) { + result.put("exists", true); + result.put("id", item.get("id").asLong()); + result.put("status", item.has("status") ? item.get("status").asText() : ""); + result.put("sslStatus", item.has("sslStatus") ? item.get("sslStatus").asText() : ""); + result.put("protocol", item.has("protocol") ? item.get("protocol").asText() : ""); + result.put("sitePath", item.has("sitePath") ? item.get("sitePath").asText() : ""); + result.put("type", item.has("type") ? item.get("type").asText() : ""); + result.put("remark", item.has("remark") ? item.get("remark").asText() : ""); + result.put("sslExpireDate", item.has("sslExpireDate") ? item.get("sslExpireDate").asText() : ""); + result.put("alias", itemAlias); + result.put("primaryDomain", itemPrimaryDomain); + return result; + } + } + } + } catch (Exception e) { + log.error("检查网站状态失败: {}", e.getMessage()); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public boolean operateWebsite(Long serverId, Long websiteId, String operate) { + if (serverId == null || websiteId == null || operate == null) { + return false; + } + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + Map params = new HashMap<>(); + params.put("id", websiteId); + params.put("operate", operate); + + String response = sendPost(server, "/api/v2/websites/operate", params); + JsonNode jsonNode = objectMapper.readTree(response); + + // 检查响应状态 + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + log.info("网站操作成功: websiteId={}, operate={}", websiteId, operate); + return true; + } else { + log.error("网站操作失败: websiteId={}, operate={}, response={}", websiteId, operate, response); + } + } catch (Exception e) { + log.error("网站操作异常: websiteId={}, operate={}", websiteId, operate, e); + } + + return false; + } + + @Override + public boolean deleteWebsite(Long serverId, Long websiteId) { + if (serverId == null || websiteId == null) { + return false; + } + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + Map params = new HashMap<>(); + params.put("id", websiteId); + params.put("deleteApp", false); + params.put("deleteBackup", true); + params.put("forceDelete", true); + params.put("deleteDB", false); + + String response = sendPost(server, "/api/v2/websites/del", params); + JsonNode jsonNode = objectMapper.readTree(response); + + // 检查响应状态 + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + log.info("网站删除成功: websiteId={}", websiteId); + return true; + } else { + log.error("网站删除失败: websiteId={}, response={}", websiteId, response); + } + } catch (Exception e) { + log.error("网站删除异常: websiteId={}", websiteId, e); + } + + return false; + } + + @Override + public List checkFileBatch(Long serverId, List paths) { + if (serverId == null || paths == null || paths.isEmpty()) { + return new ArrayList<>(); + } + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return new ArrayList<>(); + } + + try { + Map params = new HashMap<>(); + params.put("paths", paths); + + String response = sendPost(server, "/api/v2/files/batch/check", params); + log.info("Batch Check Response: {}", response); // Added Log + JsonNode jsonNode = objectMapper.readTree(response); + + List existingFiles = new ArrayList<>(); + if (jsonNode.has("data") && jsonNode.get("data").isArray()) { + for (JsonNode node : jsonNode.get("data")) { + if (node.isObject() && node.has("path")) { + existingFiles.add(node.get("path").asText()); + } else { + existingFiles.add(node.asText()); + } + } + } + log.info("Parsed Existing Files: {}", existingFiles); // Added Log + return existingFiles; + } catch (Exception e) { + log.error("批量检查文件失败", e); + throw new RuntimeException("检查文件失败: " + e.getMessage()); + } + } + + @Override + public String searchFile(Long serverId, String path) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + throw new RuntimeException("服务器不存在"); + } + + try { + Map params = new HashMap<>(); + params.put("path", path); + params.put("expand", true); + params.put("showHidden", true); + params.put("page", 1); + params.put("pageSize", 100); + params.put("search", ""); + params.put("containSub", false); + params.put("sortBy", "name"); + params.put("sortOrder", "ascending"); + + String response = sendPost(server, "/api/v2/files/search", params); + // 验证响应 + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("code") && jsonNode.get("code").asInt() != 200) { + throw new RuntimeException(jsonNode.has("message") ? jsonNode.get("message").asText() : "查询失败"); + } + return response; // 返回原始JSON响应供前端或其他逻辑处理 + } catch (Exception e) { + log.error("查询文件失败", e); + throw new RuntimeException("查询文件失败: " + e.getMessage()); + } + } + + @Override + public boolean deleteFile(Long serverId, String path, boolean isDir) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + Map params = new HashMap<>(); + params.put("path", path); + params.put("isDir", isDir); + params.put("forceDelete", true); + + String response = sendPost(server, "/api/v2/files/del", params); + JsonNode jsonNode = objectMapper.readTree(response); + + return jsonNode.has("code") && jsonNode.get("code").asInt() == 200; + } catch (Exception e) { + log.error("删除文件失败: path={}", path, e); + return false; + } + } + + @Override + public String uploadFileChunk(Long serverId, String filename, String path, int chunkIndex, int chunkCount, org.springframework.web.multipart.MultipartFile fileContent) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + throw new RuntimeException("服务器不存在"); + } + + try { + org.springframework.util.MultiValueMap body = new org.springframework.util.LinkedMultiValueMap<>(); + body.add("path", path); + body.add("overwrite", "true"); // 添加 overwrite 参数,支持覆盖已存在的文件 + + // 封装文件资源 + org.springframework.core.io.Resource fileResource = new org.springframework.core.io.ByteArrayResource(fileContent.getBytes()) { + @Override + public String getFilename() { + return filename; + } + }; + body.add("file", fileResource); + + // 使用直接上传接口替代分片上传 + return sendMultipartPost(server, "/api/v2/files/upload", body); + } catch (Exception e) { + log.error("上传失败: {}", filename, e); + throw new RuntimeException("上传失败: " + e.getMessage()); + } + } + + private String sendMultipartPost(PlatformServer server, String path, org.springframework.util.MultiValueMap body) { + String url = getBaseUrl(server) + path; + HttpHeaders headers = createHeaders(server); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> entity = new HttpEntity<>(body, headers); + try { + log.info("开始上传文件到: {}", url); + long startTime = System.currentTimeMillis(); + + // 使用专门的上传 RestTemplate,具有更长的超时时间 + ResponseEntity response = uploadRestTemplate.exchange(url, HttpMethod.POST, entity, String.class); + + long duration = System.currentTimeMillis() - startTime; + log.info("文件上传完成,耗时: {}ms", duration); + + return response.getBody(); + } catch (Exception e) { + log.error("1Panel Multipart POST请求失败: {}", url, e); + throw new RuntimeException("1Panel API请求失败: " + e.getMessage()); + } + } + + @Override + public Map checkAppInstalled(Long serverId, String key, String name) { + Map result = new HashMap<>(); + result.put("isExist", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("key", key); + params.put("name", name); + + String response = sendPost(server, "/api/v2/apps/installed/check", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("isExist", data.has("isExist") && data.get("isExist").asBoolean()); + result.put("name", data.has("name") ? data.get("name").asText() : ""); + result.put("app", data.has("app") ? data.get("app").asText() : ""); + result.put("version", data.has("version") ? data.get("version").asText() : ""); + result.put("status", data.has("status") ? data.get("status").asText() : ""); + result.put("createdAt", data.has("createdAt") ? data.get("createdAt").asText() : ""); + result.put("lastBackupAt", data.has("lastBackupAt") ? data.get("lastBackupAt").asText() : ""); + result.put("appInstallId", data.has("appInstallId") ? data.get("appInstallId").asLong() : 0); + result.put("containerName", data.has("containerName") ? data.get("containerName").asText() : ""); + result.put("installPath", data.has("installPath") ? data.get("installPath").asText() : ""); + result.put("httpPort", data.has("httpPort") ? data.get("httpPort").asInt() : 0); + result.put("httpsPort", data.has("httpsPort") ? data.get("httpsPort").asInt() : 0); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "检查失败"; + log.error("检查应用安装状态失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("检查应用安装状态异常: key={}, name={}", key, name, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map searchDatabases(Long serverId, String database, int page, int pageSize) { + Map result = new HashMap<>(); + result.put("total", 0); + result.put("items", new ArrayList<>()); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("page", page); + params.put("pageSize", pageSize); + params.put("database", database); + params.put("orderBy", "createdAt"); + params.put("order", "null"); + + String response = sendPost(server, "/api/v2/databases/search", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("total", data.has("total") ? data.get("total").asInt() : 0); + + List> items = new ArrayList<>(); + if (data.has("items") && data.get("items").isArray()) { + for (JsonNode item : data.get("items")) { + Map dbItem = new HashMap<>(); + dbItem.put("id", item.has("id") ? item.get("id").asLong() : 0); + dbItem.put("createdAt", item.has("createdAt") ? item.get("createdAt").asText() : ""); + dbItem.put("name", item.has("name") ? item.get("name").asText() : ""); + dbItem.put("from", item.has("from") ? item.get("from").asText() : ""); + dbItem.put("mysqlName", item.has("mysqlName") ? item.get("mysqlName").asText() : ""); + dbItem.put("format", item.has("format") ? item.get("format").asText() : ""); + dbItem.put("username", item.has("username") ? item.get("username").asText() : ""); + dbItem.put("password", item.has("password") ? item.get("password").asText() : ""); + dbItem.put("permission", item.has("permission") ? item.get("permission").asText() : ""); + dbItem.put("isDelete", item.has("isDelete") && item.get("isDelete").asBoolean()); + dbItem.put("description", item.has("description") ? item.get("description").asText() : ""); + items.add(dbItem); + } + } + result.put("items", items); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "查询失败"; + log.error("查询数据库列表失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("查询数据库列表异常: database={}", database, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map createDatabase(Long serverId, Map params) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendPost(server, "/api/v2/databases", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + if (jsonNode.has("data")) { + result.put("data", objectMapper.convertValue(jsonNode.get("data"), Map.class)); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "创建失败"; + log.error("创建数据库失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("创建数据库异常", e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public boolean deleteDatabase(Long serverId, Map params) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + String response = sendPost(server, "/api/v2/databases/del", params); + JsonNode jsonNode = objectMapper.readTree(response); + + return jsonNode.has("code") && jsonNode.get("code").asInt() == 200; + } catch (Exception e) { + log.error("删除数据库异常", e); + return false; + } + } + + @Override + public boolean updateDatabaseDescription(Long serverId, Map params) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + String response = sendPost(server, "/api/v2/databases/description/update", params); + JsonNode jsonNode = objectMapper.readTree(response); + + return jsonNode.has("code") && jsonNode.get("code").asInt() == 200; + } catch (Exception e) { + log.error("更新数据库描述异常", e); + return false; + } + } + + @Override + public boolean changeDatabasePassword(Long serverId, Map params) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + String response = sendPost(server, "/api/v2/databases/change/password", params); + JsonNode jsonNode = objectMapper.readTree(response); + + return jsonNode.has("code") && jsonNode.get("code").asInt() == 200; + } catch (Exception e) { + log.error("修改数据库密码异常", e); + return false; + } + } + + @Override + public boolean operateApp(Long serverId, Map params) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return false; + } + + try { + // 构造 1Panel API 需要的参数 + // 接口 /api/v2/apps/installed/op 参数通常需要 installId 和 operate + String response = sendPost(server, "/api/v2/apps/installed/op", params); + JsonNode jsonNode = objectMapper.readTree(response); + + return jsonNode.has("code") && jsonNode.get("code").asInt() == 200; + } catch (Exception e) { + log.error("操作应用失败", e); + return false; + } + } + + @Override + public List getDatabaseFormatOptions(Long serverId, String type, String database, String format) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return new ArrayList<>(); + } + + try { + // 修正:1Panel 接口参数只需要 name (对应数据库类型) + Map params = new HashMap<>(); + params.put("name", type); // 这里 type 应该是 "mysql", "postgresql" 等 + + // 下面的参数不需要发给 1Panel + // params.put("type", type); + // params.put("database", database); + // if (format != null && !format.isEmpty()) { + // params.put("format", format); + // } + + String response = sendPost(server, "/api/v2/databases/format/options", params); + JsonNode jsonNode = objectMapper.readTree(response); + + List options = new ArrayList<>(); + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null && data.isArray()) { + for (JsonNode item : data) { + String itemFormat = item.has("format") ? item.get("format").asText() : ""; + + // 如果指定了 format,则只返回匹配 format 的 collations + if (format != null && !format.isEmpty()) { + if (itemFormat.equalsIgnoreCase(format)) { + JsonNode collations = item.get("collations"); + if (collations != null && collations.isArray()) { + for (JsonNode col : collations) { + options.add(col.asText()); + } + } + break; + } + } else { + // 如果没有指定 format,为了兼容,我们可以返回所有的 collation,但这可能太多了 + // 或者返回 format 列表? + // 目前前端在 loadCollationOptions 时总是会传 createForm.format (默认 utf8mb4) + // 所以这里主要关注 format 匹配的情况。 + // 如果没传,我们可以为了调试方便,把所有 format 打印出来? + // 还是不做处理,返回空,迫使前端传 format。 + } + } + } + } + return options; + } catch (Exception e) { + log.error("获取数据库字符集选项失败", e); + return new ArrayList<>(); + } + } + + @Override + public Map getAppInfo(Long serverId, String appKey) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendGet(server, "/api/v2/apps/" + appKey); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result = objectMapper.convertValue(data, Map.class); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取应用信息失败"; + log.error("获取应用信息失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("获取应用信息异常: appKey={}", appKey, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map getAppDetail(Long serverId, Long appId, String version) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendGet(server, "/api/v2/apps/detail/" + appId + "/" + version + "/app"); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result = objectMapper.convertValue(data, Map.class); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取应用详情失败"; + log.error("获取应用详情失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("获取应用详情异常: appId={}, version={}", appId, version, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map installApp(Long serverId, Map params) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendPost(server, "/api/v2/apps/install", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + if (jsonNode.has("data")) { + result.put("data", objectMapper.convertValue(jsonNode.get("data"), Map.class)); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "安装失败"; + log.error("安装应用失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("安装应用异常", e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map readTaskLog(Long serverId, String taskId, int page, int pageSize) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("id", 0); + params.put("type", "task"); + params.put("name", ""); + params.put("page", page); + params.put("pageSize", pageSize); + params.put("latest", false); + params.put("taskID", taskId); + params.put("taskType", ""); + params.put("taskOperate", ""); + params.put("resourceID", 0); + + String response = sendPost(server, "/api/v2/files/read", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("end", data.has("end") && data.get("end").asBoolean()); + result.put("path", data.has("path") ? data.get("path").asText() : ""); + result.put("total", data.has("total") ? data.get("total").asInt() : 0); + result.put("taskStatus", data.has("taskStatus") ? data.get("taskStatus").asText() : ""); + result.put("totalLines", data.has("totalLines") ? data.get("totalLines").asInt() : 0); + + List lines = new ArrayList<>(); + if (data.has("lines") && data.get("lines").isArray()) { + for (JsonNode line : data.get("lines")) { + lines.add(line.asText()); + } + } + result.put("lines", lines); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "读取日志失败"; + log.error("读取任务日志失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("读取任务日志异常: taskId={}", taskId, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map searchRuntimes(Long serverId, String type, int page, int pageSize) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("type", type); + params.put("page", page); + params.put("pageSize", pageSize); + + String response = sendPost(server, "/api/v2/runtimes/search", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("total", data.has("total") ? data.get("total").asInt() : 0); + + List> items = new ArrayList<>(); + if (data.has("items") && data.get("items").isArray()) { + for (JsonNode item : data.get("items")) { + items.add(objectMapper.convertValue(item, Map.class)); + } + } + result.put("items", items); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "搜索运行时失败"; + log.error("搜索运行时失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("搜索运行时异常: type={}", type, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map syncRuntimes(Long serverId) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendPost(server, "/api/v2/runtimes/sync", new HashMap<>()); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "同步运行时失败"; + log.error("同步运行时失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("同步运行时异常", e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map operateRuntime(Long serverId, Long id, String operate) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // Map frontend operate values to 1Panel API values + String panelOperate = operate; + switch (operate) { + case "start": + panelOperate = "up"; + break; + case "stop": + panelOperate = "down"; + break; + case "restart": + panelOperate = "restart"; + break; + } + + Map params = new HashMap<>(); + params.put("ID", id.intValue()); // 1Panel expects uppercase ID + params.put("operate", panelOperate); + + log.info("操作运行时: id={}, operate={}, params={}", id, panelOperate, params); + + String response = sendPost(server, "/api/v2/runtimes/operate", params); + log.info("操作运行时响应: {}", response); + + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "操作运行时失败"; + log.error("操作运行时失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("操作运行时异常: id={}, operate={}", id, operate, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map deleteRuntime(Long serverId, Long id, boolean forceDelete, boolean deleteFolder, String codeDir) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("id", id); + params.put("forceDelete", forceDelete); + + String response = sendPost(server, "/api/v2/runtimes/del", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + + // 如果需要删除关联文件夹 + if (deleteFolder && codeDir != null && !codeDir.isEmpty() && !"/".equals(codeDir)) { + try { + log.info("删除运行时关联目录: {}", codeDir); + Map deleteParams = new HashMap<>(); + deleteParams.put("path", codeDir); + deleteParams.put("isDir", true); + deleteParams.put("forceDelete", true); + + String deleteResponse = sendPost(server, "/api/v2/files/del", deleteParams); + JsonNode deleteNode = objectMapper.readTree(deleteResponse); + + if (deleteNode.has("code") && deleteNode.get("code").asInt() == 200) { + log.info("目录删除成功: {}", codeDir); + result.put("folderDeleted", true); + } else { + String errMsg = deleteNode.has("message") ? deleteNode.get("message").asText() : "删除目录失败"; + log.warn("删除目录失败: {} - {}", codeDir, errMsg); + result.put("folderDeleteError", errMsg); + } + } catch (Exception e) { + log.warn("删除目录异常: {} - {}", codeDir, e.getMessage()); + result.put("folderDeleteError", e.getMessage()); + } + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "删除运行时失败"; + log.error("删除运行时失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("删除运行时异常: id={}", id, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map searchRuntimeApps(Long serverId, String type, int page, int pageSize) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("type", type); + params.put("page", page); + params.put("pageSize", pageSize); + params.put("resource", "remote"); + + String response = sendPost(server, "/api/v2/apps/search", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("total", data.has("total") ? data.get("total").asInt() : 0); + + List> items = new ArrayList<>(); + if (data.has("items") && data.get("items").isArray()) { + for (JsonNode item : data.get("items")) { + items.add(objectMapper.convertValue(item, Map.class)); + } + } + result.put("items", items); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "搜索运行时应用失败"; + log.error("搜索运行时应用失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("搜索运行时应用异常: type={}", type, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map getRuntimeDetail(Long serverId, Long appId, String version) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String path = "/api/v2/apps/detail/" + appId + "/" + version + "/runtime"; + String response = sendGet(server, path); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result = objectMapper.convertValue(data, Map.class); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取运行时版本详情失败"; + log.error("获取运行时版本详情失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("获取运行时版本详情异常: appId={}, version={}", appId, version, e); + result.put("error", e.getMessage()); + } + + return result; + } + + /** + * 辅助方法:发送创建运行时请求 + */ + private Map doCreateRuntime(PlatformServer server, Map params) throws Exception { + Map result = new HashMap<>(); + String response = sendPost(server, "/api/v2/runtimes", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "创建运行时失败"; + result.put("success", false); + result.put("error", errMsg); + } + return result; + } + + @Override + public Map createRuntime(Long serverId, Map params) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // 第一次尝试创建 + Map createResult = doCreateRuntime(server, params); + if ((Boolean) createResult.getOrDefault("success", false)) { + return createResult; + } + + String error = (String) createResult.get("error"); + // 检查是否为目录已存在错误: "rename ... dest: file exists" + // 示例: rename /opt/1panel/runtime/java/21 /opt/1panel/runtime/java/superJava: file exists + if (error != null && error.contains("rename") && error.contains("file exists")) { + log.info("检测到运行环境目录冲突,尝试清理后重试: {}", error); + + String[] parts = error.split("\\s+"); + String destPath = null; + // 简单的查找逻辑:找到 : 前面的路径 + for (int i = 0; i < parts.length; i++) { + if (parts[i].endsWith(":") && i > 0) { + destPath = parts[i-1]; + break; + } + } + + // 确保路径包含 runtime 关键字以防误删 + if (destPath != null && destPath.contains("/runtime/")) { + log.info("正在清理冲突目录: {}", destPath); + // 尝试删除冲突目录 + Map delResult = this.deleteFile(serverId, destPath, Boolean.TRUE); + if ((Boolean) delResult.getOrDefault("success", false)) { + // 删除成功,重试创建 + log.info("清理成功,重试创建运行时"); + return doCreateRuntime(server, params); + } else { + log.warn("自动清理失败: {}", delResult.get("error")); + } + } + } + + result.put("error", error); + } catch (Exception e) { + log.error("创建运行时异常", e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map listFiles(Long serverId, String path) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("path", path); + params.put("expand", true); + params.put("page", 1); + params.put("pageSize", 100); + params.put("showHidden", true); + params.put("search", ""); + params.put("containSub", false); + params.put("dir", false); + + String response = sendPost(server, "/api/v2/files/search", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + List> items = new ArrayList<>(); + if (data.has("items") && data.get("items").isArray()) { + for (JsonNode item : data.get("items")) { + Map fileItem = new HashMap<>(); + fileItem.put("name", item.has("name") ? item.get("name").asText() : ""); + fileItem.put("isDir", item.has("isDir") && item.get("isDir").asBoolean()); + fileItem.put("size", item.has("size") ? item.get("size").asLong() : 0); + fileItem.put("modTime", item.has("modTime") ? item.get("modTime").asText() : ""); + items.add(fileItem); + } + } + result.put("items", items); + result.put("total", data.has("total") ? data.get("total").asInt() : items.size()); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取文件列表失败"; + log.error("获取文件列表失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("获取文件列表异常: path={}", path, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map mkdir(Long serverId, String path) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // 解析路径和名称 + // 如果路径以/结尾,先去掉 + String fullPath = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; + int lastSlashIndex = fullPath.lastIndexOf("/"); + + String parentPath; + String name; + + if (lastSlashIndex == -1) { + // 没有斜杠,可能是相对路径或者根目录下文件(不应该出现的情况,path通常是绝对路径) + parentPath = "/"; + name = fullPath; + } else if (lastSlashIndex == 0) { + // 根目录下的目录,例如 /app + parentPath = "/"; + name = fullPath.substring(1); + } else { + // 普通情况,例如 /opt/app + parentPath = fullPath.substring(0, lastSlashIndex); + name = fullPath.substring(lastSlashIndex + 1); + } + + Map params = new HashMap<>(); + params.put("path", fullPath); // 修正:这里使用完整路径,而不是父目录 + params.put("name", name); // 文件夹名称 + params.put("isDir", true); + params.put("mode", 493); // 0755 + params.put("isLink", false); + params.put("isSymlink", true); // 根据您的反馈,将此字段设为true(尽管通常用于软链,但既然您的正确参数里是true,我们就保持一致) + params.put("linkPath", ""); + + String response = sendPost(server, "/api/v2/files", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "创建目录失败"; + log.error("创建目录失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("创建目录异常: path={}", path, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map deleteFile(Long serverId, String path, Boolean isDir) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("path", path); + params.put("isDir", isDir); + params.put("forceDelete", true); // 强制删除,包含非空目录 + + // 使用单个删除接口 /api/v2/files/del + String response = sendPost(server, "/api/v2/files/del", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "删除失败"; + log.error("删除文件失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("删除文件异常: path={}", path, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map createFile(Long serverId, String path) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // 解析路径和名称 + // 如果路径以/结尾,先去掉 + String fullPath = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; + int lastSlashIndex = fullPath.lastIndexOf("/"); + + String name; + + if (lastSlashIndex == -1) { + name = fullPath; + } else if (lastSlashIndex == 0) { + name = fullPath.substring(1); + } else { + name = fullPath.substring(lastSlashIndex + 1); + } + + Map params = new HashMap<>(); + params.put("path", fullPath); + params.put("name", name); + params.put("isDir", false); // 创建文件 + params.put("mode", 420); // 0644 for files usually, or use user provided example default? + // 用户没有给出mode值,mkdir是493(755)。文件一般是644(420)。 + // 让我们看看用户提供的参数:没有mode。 + // 用户之前mkdir的例子给了mode 493。 + // 对于文件,我们暂用 420 (0644)。 + params.put("isLink", false); + params.put("isSymlink", true); // Follow existing pattern requested by user + params.put("linkPath", ""); + + String response = sendPost(server, "/api/v2/files", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "创建文件失败"; + log.error("创建文件失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("创建文件异常: path={}", path, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map uploadFile(Long serverId, String path, org.springframework.web.multipart.MultipartFile file) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + org.springframework.util.MultiValueMap body = new org.springframework.util.LinkedMultiValueMap<>(); + body.add("path", path); + + // 使用 InputStreamResource 替代 ByteArrayResource,避免大文件内存占用 + body.add("file", new org.springframework.core.io.InputStreamResource(file.getInputStream()) { + @Override + public String getFilename() { + return file.getOriginalFilename(); + } + + @Override + public long contentLength() { + return file.getSize(); + } + }); + + // 获取通用Header(包含认证) + HttpHeaders headers = createHeaders(server); + // 覆盖ContentType + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + String url = getBaseUrl(server) + "/api/v2/files/upload"; + log.info("开始上传文件到: {},文件大小: {} bytes", url, file.getSize()); + long startTime = System.currentTimeMillis(); + + // 使用专门的上传 RestTemplate,具有更长的超时时间 + ResponseEntity response = uploadRestTemplate.postForEntity(url, requestEntity, String.class); + + long duration = System.currentTimeMillis() - startTime; + log.info("文件上传完成,耗时: {}ms", duration); + + JsonNode jsonNode = objectMapper.readTree(response.getBody()); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "上传文件失败"; + log.error("上传文件失败: {}", errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("上传文件异常: path={}", path, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map getContainerLog(Long serverId, String containerName, String composePath) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + if (composePath != null && !composePath.isEmpty()) { + // 优先使用 compose 路径方式获取日志 (适合 1Panel 运行时) + // RestTemplate 会自动处理 URL 编码,不需要手动 Encode,否则会导致双重编码 + String url = "/api/v2/containers/search/log?compose=" + composePath + "&tail=100&follow=false&operateNode=local&since=all"; + + // 使用 sendGet 调用 + String response = sendGet(server, url); + + if (response == null) { + result.put("success", true); + result.put("log", ""); + } else if (response.trim().startsWith("{")) { + // 尝试解析 JSON + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + JsonNode data = jsonNode.get("data"); + result.put("log", data != null ? data.asText() : ""); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取日志失败"; + result.put("error", errMsg); + } + } else { + // 如果不是 JSON,可能直接就是日志内容 + result.put("success", true); + result.put("log", response); + } + } else { + // 原有逻辑:直接根据容器名获取 + Map params = new HashMap<>(); + params.put("name", containerName); + params.put("tail", 200); + + String response = sendPost(server, "/api/v2/containers/log", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + JsonNode data = jsonNode.get("data"); + result.put("log", data != null ? data.asText() : ""); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取日志失败"; + result.put("error", errMsg); + } + } + } catch (Exception e) { + log.error("获取容器日志异常: {}", containerName, e); + result.put("error", e.getMessage()); + } + return result; + } + + + @Override + public Map updateRuntime(Long serverId, Map params) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // 1Panel的更新接口通常是 POST /api/v2/runtimes/update + String response = sendPost(server, "/api/v2/runtimes/update", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "更新运行时失败"; + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("更新运行时异常", e); + result.put("error", e.getMessage()); + } + + return result; + } + @Override + public java.util.List> getNodeScripts(Long serverId, String codeDir) { + java.util.List> result = new java.util.ArrayList<>(); + PlatformServer server = serverService.getById(serverId); + if (server == null) { + return result; + } + + try { + java.util.Map params = new java.util.HashMap<>(); + params.put("codeDir", codeDir); + + String response = sendPost(server, "/api/v2/runtimes/node/package", params); + log.info("Node Scripts Response: {}", response); + + com.fasterxml.jackson.databind.JsonNode jsonNode = objectMapper.readTree(response); + com.fasterxml.jackson.databind.JsonNode data = null; + + if (jsonNode.isArray()) { + data = jsonNode; + } else if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + data = jsonNode.get("data"); + } + + if (data != null && data.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode item : data) { + java.util.Map script = new java.util.HashMap<>(); + String name = ""; + String scriptContent = ""; + + if (item.isObject()) { + if (item.has("name")) name = item.get("name").asText(); + if (item.has("script")) scriptContent = item.get("script").asText(); + } else if (item.isTextual()) { + name = item.asText(); + scriptContent = name; + } + + script.put("name", name); + script.put("script", scriptContent); + result.add(script); + } + } + } catch (Exception e) { + log.error("获取Node脚本异常", e); + } + return result; + } + + @Override + public Map uploadChunk(Long serverId, String path, String filename, + int chunkIndex, int chunkCount, long fileSize, String uploadId, + org.springframework.web.multipart.MultipartFile chunk) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + // 1Panel chunkupload 接口参数 + org.springframework.util.MultiValueMap body = new org.springframework.util.LinkedMultiValueMap<>(); + + // path 应该是目标目录路径 + body.add("path", path); + + // 封装分片文件 - 使用原始文件名 + org.springframework.core.io.Resource fileResource = new org.springframework.core.io.ByteArrayResource(chunk.getBytes()) { + @Override + public String getFilename() { + return filename; + } + }; + body.add("file", fileResource); + + HttpHeaders headers = createHeaders(server); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // 1Panel 的分片上传可能使用不同的 URL 或参数 + // 先尝试直接使用 /api/v2/files/upload 接口,因为它可能支持分片 + String url = getBaseUrl(server) + "/api/v2/files/upload"; + log.info("分片上传: {} - 分片 {}/{} (使用标准上传接口)", filename, chunkIndex + 1, chunkCount); + + ResponseEntity response = uploadRestTemplate.postForEntity(url, requestEntity, String.class); + + JsonNode jsonNode = objectMapper.readTree(response.getBody()); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + result.put("chunkIndex", chunkIndex); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "分片上传失败"; + log.error("分片上传失败: {} - 分片 {}/{}, 错误: {}", filename, chunkIndex + 1, chunkCount, errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("分片上传异常: filename={}, chunkIndex={}", filename, chunkIndex, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map mergeChunks(Long serverId, String path, String filename, + int chunkCount, long fileSize, String uploadId) { + Map result = new HashMap<>(); + result.put("success", false); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + Map params = new HashMap<>(); + params.put("path", path); + params.put("filename", filename); + params.put("chunkCount", chunkCount); + params.put("fileSize", fileSize); + params.put("uploadId", uploadId); + + log.info("合并分片: {} - 共 {} 个分片, 文件大小: {} bytes", filename, chunkCount, fileSize); + + String response = sendPost(server, "/api/v2/files/merge", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + result.put("success", true); + log.info("分片合并成功: {}", filename); + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "合并分片失败"; + log.error("合并分片失败: {}, 错误: {}", filename, errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("合并分片异常: filename={}", filename, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public Map getWebsiteNginxConfig(Long serverId, Long websiteId) { + Map result = new HashMap<>(); + + PlatformServer server = serverService.getById(serverId); + if (server == null) { + result.put("error", "服务器不存在"); + return result; + } + + try { + String response = sendGet(server, "/api/v2/websites/" + websiteId + "/config/openresty"); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + JsonNode data = jsonNode.get("data"); + if (data != null) { + result.put("path", data.has("path") ? data.get("path").asText() : ""); + result.put("content", data.has("content") ? data.get("content").asText() : ""); + result.put("name", data.has("name") ? data.get("name").asText() : ""); + } + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "获取Nginx配置失败"; + log.error("获取Nginx配置失败: websiteId={}, error={}", websiteId, errMsg); + result.put("error", errMsg); + } + } catch (Exception e) { + log.error("获取Nginx配置异常: websiteId={}", websiteId, e); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public boolean saveWebsiteNginxConfig(Long serverId, Long websiteId, String content) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + log.error("保存Nginx配置失败: 服务器不存在, serverId={}", serverId); + return false; + } + + try { + Map params = new HashMap<>(); + params.put("id", websiteId); + params.put("content", content); + + String response = sendPost(server, "/api/v2/websites/nginx/update", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + log.info("保存Nginx配置成功: websiteId={}", websiteId); + return true; + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "保存失败"; + log.error("保存Nginx配置失败: websiteId={}, error={}", websiteId, errMsg); + return false; + } + } catch (Exception e) { + log.error("保存Nginx配置异常: websiteId={}", websiteId, e); + return false; + } + } + + @Override + public boolean reloadNginx(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null) { + log.error("重载Nginx失败: 服务器不存在, serverId={}", serverId); + return false; + } + + try { + Map params = new HashMap<>(); + params.put("operate", "restart"); + + String response = sendPost(server, "/api/v2/openresty/operate", params); + JsonNode jsonNode = objectMapper.readTree(response); + + if (jsonNode.has("code") && jsonNode.get("code").asInt() == 200) { + log.info("重载Nginx成功: serverId={}", serverId); + return true; + } else { + String errMsg = jsonNode.has("message") ? jsonNode.get("message").asText() : "重载失败"; + log.error("重载Nginx失败: serverId={}, error={}", serverId, errMsg); + return false; + } + } catch (Exception e) { + log.error("重载Nginx异常: serverId={}", serverId, e); + return false; + } + } +} + diff --git a/src/main/java/com/nanxiislet/admin/service/impl/PlatformCertificateServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/PlatformCertificateServiceImpl.java new file mode 100644 index 0000000..255b169 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/PlatformCertificateServiceImpl.java @@ -0,0 +1,665 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.dto.CertificateApplyRequest; +import com.nanxiislet.admin.dto.CertificateApplyResult; +import com.nanxiislet.admin.entity.PlatformCertificate; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.mapper.PlatformCertificateMapper; +import com.nanxiislet.admin.service.PlatformCertificateService; +import com.nanxiislet.admin.service.PlatformServerService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +/** + * 证书管理服务实现 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Slf4j +@Service +public class PlatformCertificateServiceImpl extends ServiceImpl + implements PlatformCertificateService { + + @Resource + private PlatformServerService serverService; + + @Resource + private ObjectMapper objectMapper; + + private final RestTemplate restTemplate = new RestTemplate(); + + // ==================== 1Panel API 工具方法 ==================== + + private String generateToken(String apiKey, long timestamp) { + String rawToken = "1panel" + apiKey + timestamp; + return DigestUtils.md5DigestAsHex(rawToken.getBytes()); + } + + private String sendGet(PlatformServer server, String path) { + return doRequest1Panel(server, path, HttpMethod.GET, null); + } + + private String sendPost(PlatformServer server, String path, Object body) { + return doRequest1Panel(server, path, HttpMethod.POST, body); + } + + private String doRequest1Panel(PlatformServer server, String path, HttpMethod method, Object body) { + String baseUrl = ""; + String apiPath = server.getPanelUrl(); + String pathPrefix = ""; + + if (StringUtils.hasText(apiPath)) { + if (apiPath.endsWith("/")) apiPath = apiPath.substring(0, apiPath.length() - 1); + baseUrl = apiPath; + // 提取 path prefix (e.g. /super) + try { + java.net.URI uri = new java.net.URI(baseUrl); + pathPrefix = uri.getPath(); + // 重新构建 base (host:port) + baseUrl = uri.getScheme() + "://" + uri.getHost() + ":" + uri.getPort(); + } catch (Exception e) { + log.warn("解析panelUrl失败: {}", apiPath); + } + } else { + String ip = server.getIp(); + Integer port = server.getPanelPort() != null ? server.getPanelPort() : 42588; + baseUrl = "http://" + ip + ":" + port; + } + + // 构造尝试列表 + List tryPaths = new ArrayList<>(); + if (StringUtils.hasText(pathPrefix)) { + tryPaths.add(pathPrefix + "/api/v2"); + tryPaths.add(pathPrefix + "/api/v1"); + } + tryPaths.add("/api/v2"); + tryPaths.add("/api/v1"); + + // 去重 + List distinctPaths = tryPaths.stream().distinct().toList(); + + for (String versionPath : distinctPaths) { + String fullUrl = baseUrl + versionPath + path; + + long localTimestamp = System.currentTimeMillis() / 1000; + ResponseEntity responseEntity = executeRequest(fullUrl, method, body, server.getPanelApiKey(), localTimestamp); + String responseBody = responseEntity.getBody(); + + boolean isHtml = responseBody != null && responseBody.trim().startsWith("<"); + + if (responseEntity.getStatusCode().is2xxSuccessful() && !isHtml) { + log.debug("1Panel API 请求成功: {}", fullUrl); + return responseBody; + } + + if (isHtml) { + // 尝试校准时间 + long serverTimestamp = -1; + long responseDate = responseEntity.getHeaders().getDate(); + if (responseDate > 0) serverTimestamp = responseDate / 1000; + else serverTimestamp = localTimestamp - 31536000; + + if (serverTimestamp > 0) { + ResponseEntity retryResponse = executeRequest(fullUrl, method, body, server.getPanelApiKey(), serverTimestamp); + String retryBody = retryResponse.getBody(); + if (retryBody != null && !retryBody.trim().startsWith("<")) { + return retryBody; + } + } + } + } + + log.error("1Panel API 所有路径尝试失败: path={}", path); + return null; + } + + private ResponseEntity executeRequest(String url, HttpMethod method, Object body, String apiKey, long timestamp) { + String token = generateToken(apiKey, timestamp); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("1Panel-Token", token); + headers.set("1Panel-Timestamp", String.valueOf(timestamp)); + headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + try { + String jsonBody = body != null ? objectMapper.writeValueAsString(body) : null; + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + return restTemplate.exchange(url, method, entity, String.class); + } catch (Exception e) { + if (e instanceof org.springframework.web.client.HttpStatusCodeException) { + org.springframework.web.client.HttpStatusCodeException se = (org.springframework.web.client.HttpStatusCodeException) e; + return new ResponseEntity<>(se.getResponseBodyAsString(), se.getResponseHeaders(), se.getStatusCode()); + } + log.error("Request 1Panel Error: {} {}", url, e.getMessage()); + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + // ==================== 业务方法 ==================== + + @Override + public Page listPage(Long serverId, BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (serverId != null) { + wrapper.eq(PlatformCertificate::getServerId, serverId); + } + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.like(PlatformCertificate::getPrimaryDomain, query.getKeyword()); + } + + wrapper.orderByDesc(PlatformCertificate::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public CertificateApplyResult applyCertificate(CertificateApplyRequest request) { + CertificateApplyResult result = CertificateApplyResult.success("证书申请开始"); + + try { + // 1. 获取服务器信息 + PlatformServer server = serverService.getById(request.getServerId()); + if (server == null) { + return CertificateApplyResult.failed("服务器不存在"); + } + + if (!StringUtils.hasText(server.getPanelApiKey())) { + return CertificateApplyResult.failed("服务器未配置1Panel API密钥"); + } + + // 2. 先保存本地记录 + PlatformCertificate certificate = new PlatformCertificate(); + certificate.setServerId(request.getServerId()); + certificate.setServerName(server.getName()); + certificate.setPrimaryDomain(request.getPrimaryDomain()); + certificate.setOtherDomains(request.getOtherDomains()); + certificate.setProvider("dnsAccount"); + certificate.setAcmeAccountId(request.getAcmeAccountId()); + certificate.setDnsAccountId(request.getDnsAccountId()); + certificate.setKeyType(request.getKeyType()); + certificate.setAutoRenew(request.getAutoRenew()); + certificate.setDescription(request.getDescription()); + certificate.setStatus("pending"); + save(certificate); + result.setCertificateId(certificate.getId()); + result.addSuccessStep("创建本地记录", "记录ID: " + certificate.getId()); + + // 3. 调用1Panel申请证书 + Map applyParams = new HashMap<>(); + applyParams.put("primaryDomain", request.getPrimaryDomain()); + // 尝试将otherDomains转为List,如果包含逗号 + if (StrUtil.isNotBlank(request.getOtherDomains())) { + if (request.getOtherDomains().contains(",")) { + applyParams.put("otherDomains", Arrays.asList(request.getOtherDomains().split(","))); + } else { + applyParams.put("otherDomains", request.getOtherDomains()); + } + } + applyParams.put("provider", "dnsAccount"); + applyParams.put("acmeAccountId", request.getAcmeAccountId()); + applyParams.put("dnsAccountId", request.getDnsAccountId()); + applyParams.put("autoRenew", request.getAutoRenew()); + + // 转换 KeyType + String keyType = request.getKeyType(); + if ("EC 256".equals(keyType)) keyType = "P256"; + else if ("EC 384".equals(keyType)) keyType = "P384"; + else if ("RSA 2048".equals(keyType)) keyType = "2048"; + else if ("RSA 4096".equals(keyType)) keyType = "4096"; + applyParams.put("keyType", keyType); + + applyParams.put("pushDirId", 0); // 默认不推送 + applyParams.put("apply", true); + + log.info("申请证书请求参数: {}", objectMapper.writeValueAsString(applyParams)); + + try { + String resp = sendPost(server, "/websites/ssl", applyParams); + if (resp == null) { + throw new RuntimeException("请求1Panel API返回为空,请检查日志"); + } + log.info("申请证书响应: {}", resp); + + // 检查响应中的 code + JsonNode respNode = objectMapper.readTree(resp); + if (respNode.has("code") && respNode.get("code").asInt() != 200) { + String errorMsg = respNode.has("message") ? respNode.get("message").asText() : "未知错误"; + throw new RuntimeException("1Panel返回错误: " + errorMsg); + } + + result.addSuccessStep("提交证书申请", "已提交到1Panel"); + } catch (Exception e) { + log.error("申请证书失败,尝试使用domains列表参数", e); + // 尝试备用参数结构:domains 列表包含主域名和其他域名 + Map retryParams = new HashMap<>(applyParams); + List domains = new ArrayList<>(); + domains.add(request.getPrimaryDomain()); + if (StrUtil.isNotBlank(request.getOtherDomains())) { + domains.addAll(Arrays.asList(request.getOtherDomains().split(","))); + } + retryParams.put("domains", domains); + retryParams.remove("otherDomains"); + + try { + String resp = sendPost(server, "/websites/ssl", retryParams); + if (resp == null) throw new RuntimeException("重试请求返回为空"); + + log.info("重试申请证书响应: {}", resp); + JsonNode respNode = objectMapper.readTree(resp); + if (respNode.has("code") && respNode.get("code").asInt() != 200) { + String errorMsg = respNode.has("message") ? respNode.get("message").asText() : "未知错误"; + throw new RuntimeException("1Panel返回错误(重试): " + errorMsg); + } + + result.addSuccessStep("提交证书申请", "已提交到1Panel(重试)"); + } catch (Exception ex) { + result.addFailedStep("提交证书申请", ex.getMessage()); + certificate.setStatus("error"); + updateById(certificate); + result.setSuccess(false); + result.setMessage("证书申请失败: " + ex.getMessage()); + return result; + } + } + + // 4. 等待并获取证书ID + Long panelSslId = null; + for (int i = 0; i < 30; i++) { // 最多等待150秒 + Thread.sleep(5000); + + String response = sendPost(server, "/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + if (request.getPrimaryDomain().equals(item.get("primaryDomain").asText())) { + panelSslId = item.get("id").asLong(); + + // 更新本地记录 + certificate.setPanelSslId(panelSslId); + certificate.setStatus("valid"); + certificate.setOrganization(item.has("organization") ? item.get("organization").asText() : "Let's Encrypt"); + + if (item.has("startDate")) { + String startDate = item.get("startDate").asText(); + if (startDate.length() >= 10) { + certificate.setStartDate(LocalDate.parse(startDate.substring(0, 10))); + } + } + if (item.has("expireDate")) { + String expireDate = item.get("expireDate").asText(); + if (expireDate.length() >= 10) { + certificate.setExpireDate(LocalDate.parse(expireDate.substring(0, 10))); + } + } + + certificate.setLastSyncTime(LocalDateTime.now()); + updateById(certificate); + + result.setPanelSslId(panelSslId); + result.addSuccessStep("证书申请完成", "1Panel证书ID: " + panelSslId); + break; + } + } + } + + if (panelSslId != null) break; + } + + if (panelSslId == null) { + result.addFailedStep("获取证书", "等待超时"); + certificate.setStatus("error"); + updateById(certificate); + result.setSuccess(false); + result.setMessage("证书申请超时"); + return result; + } + + result.setSuccess(true); + result.setMessage("证书申请成功"); + return result; + + } catch (Exception e) { + log.error("申请证书失败", e); + result.setSuccess(false); + result.setMessage("申请失败: " + e.getMessage()); + result.addFailedStep("异常", e.getMessage()); + return result; + } + } + + @Override + public int syncCertificatesFromPanel(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return 0; + } + + int syncCount = 0; + + try { + String response = sendPost(server, "/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 1000 + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + Long panelSslId = item.get("id").asLong(); + String primaryDomain = item.get("primaryDomain").asText(); + + // 查找本地是否已有该证书 + PlatformCertificate cert = getOne(new LambdaQueryWrapper() + .eq(PlatformCertificate::getServerId, serverId) + .eq(PlatformCertificate::getPanelSslId, panelSslId)); + + if (cert == null) { + cert = new PlatformCertificate(); + cert.setServerId(serverId); + cert.setServerName(server.getName()); + cert.setPanelSslId(panelSslId); + } + + // 更新信息 + cert.setPrimaryDomain(primaryDomain); + cert.setOtherDomains(item.has("domains") ? item.get("domains").asText() : null); + cert.setCn(item.has("type") ? item.get("type").asText() : null); // type字段对应CN + cert.setOrganization(item.has("organization") ? item.get("organization").asText() : "Let's Encrypt"); + cert.setProvider(item.has("provider") ? item.get("provider").asText() : "dnsAccount"); + cert.setAutoRenew(item.has("autoRenew") && item.get("autoRenew").asBoolean()); + cert.setDescription(item.has("description") ? item.get("description").asText() : null); + cert.setKeyType(item.has("keyType") ? item.get("keyType").asText() : null); + cert.setStatus("valid"); + + if (item.has("acmeAccountId")) { + cert.setAcmeAccountId(item.get("acmeAccountId").asLong()); + } + if (item.has("dnsAccountId")) { + cert.setDnsAccountId(item.get("dnsAccountId").asLong()); + } + if (item.has("startDate")) { + String startDate = item.get("startDate").asText(); + if (startDate.length() >= 10) { + cert.setStartDate(LocalDate.parse(startDate.substring(0, 10))); + } + } + if (item.has("expireDate")) { + String expireDate = item.get("expireDate").asText(); + if (expireDate.length() >= 10) { + cert.setExpireDate(LocalDate.parse(expireDate.substring(0, 10))); + + // 检查是否过期 + if (cert.getExpireDate().isBefore(LocalDate.now())) { + cert.setStatus("expired"); + } + } + } + + cert.setLastSyncTime(LocalDateTime.now()); + saveOrUpdate(cert); + syncCount++; + } + } + } catch (Exception e) { + log.error("同步证书失败", e); + } + + return syncCount; + } + + @Override + public PlatformCertificate getCertificateDetail(Long id) { + PlatformCertificate cert = getById(id); + if (cert == null || cert.getPanelSslId() == null) { + return cert; + } + + // 从1Panel获取证书内容 + PlatformServer server = serverService.getById(cert.getServerId()); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return cert; + } + + try { + String response = sendGet(server, "/websites/ssl/" + cert.getPanelSslId()); + if (response == null) { + log.warn("获取证书详情失败: 服务器未返回数据"); + return cert; + } + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null) { + // 获取证书内容 + if (data.has("pem")) { + cert.setCertContent(data.get("pem").asText()); + } + // 获取私钥内容 + if (data.has("privateKey")) { + cert.setKeyContent(data.get("privateKey").asText()); + } + // 获取CN(证书主体名称) + if (data.has("type") && cert.getCn() == null) { + cert.setCn(data.get("type").asText()); + } + // 获取其他字段 + if (data.has("organization") && cert.getOrganization() == null) { + cert.setOrganization(data.get("organization").asText()); + } + } + } catch (Exception e) { + log.error("获取证书详情失败", e); + } + + return cert; + } + + @Override + public boolean deleteCertificate(Long id) { + PlatformCertificate cert = getById(id); + if (cert == null) { + return false; + } + + // 从1Panel删除 + if (cert.getPanelSslId() != null && cert.getServerId() != null) { + PlatformServer server = serverService.getById(cert.getServerId()); + if (server != null && StringUtils.hasText(server.getPanelApiKey())) { + try { + Map params = new HashMap<>(); + List ids = new ArrayList<>(); + ids.add(cert.getPanelSslId()); + params.put("ids", ids); + + log.info("尝试从1Panel删除证书: server={}, path=/websites/ssl/del, params={}", server.getName(), params); + + String resp = sendPost(server, "/websites/ssl/del", params); + log.info("1Panel删除响应: {}", resp); + + // 如果返回可能是404或空,尝试备用路径 + if (resp == null || resp.contains("404")) { + log.info("尝试备用路径 /websites/ssl/delete"); + sendPost(server, "/websites/ssl/delete", params); + } + } catch (Exception e) { + log.error("从1Panel删除证书失败", e); + } + } + } + + // 删除本地记录 + return removeById(id); + } + + @Override + public boolean updateCertificateSettings(Long id, Boolean autoRenew, String description) { + PlatformCertificate cert = getById(id); + if (cert == null) { + return false; + } + + // 更新1Panel + if (cert.getPanelSslId() != null && cert.getServerId() != null) { + PlatformServer server = serverService.getById(cert.getServerId()); + if (server != null && StringUtils.hasText(server.getPanelApiKey())) { + try { + Map params = new HashMap<>(); + params.put("id", cert.getPanelSslId()); + params.put("autoRenew", autoRenew); + params.put("description", description); + sendPost(server, "/websites/ssl/update", params); + } catch (Exception e) { + log.error("更新1Panel证书设置失败", e); + } + } + } + + // 更新本地 + cert.setAutoRenew(autoRenew); + cert.setDescription(description); + return updateById(cert); + } + + @Override + public List> getAcmeAccounts(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return Collections.emptyList(); + } + + try { + String response = sendPost(server, "/websites/acme/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + if (response == null) { + log.warn("获取Acme账户失败: 服务器{}未返回数据", serverId); + return Collections.emptyList(); + } + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + List> result = new ArrayList<>(); + + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + Map account = new HashMap<>(); + account.put("id", item.get("id").asLong()); + account.put("email", item.get("email").asText()); + account.put("type", item.has("type") ? item.get("type").asText() : "letsencrypt"); + result.add(account); + } + } + return result; + } catch (Exception e) { + log.error("获取Acme账户失败", e); + return Collections.emptyList(); + } + } + + @Override + public List> getDnsAccounts(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return Collections.emptyList(); + } + + try { + String response = sendPost(server, "/websites/dns/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + if (response == null) { + log.warn("获取DNS账户失败: 服务器{}未返回数据", serverId); + return Collections.emptyList(); + } + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + List> result = new ArrayList<>(); + + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + Map account = new HashMap<>(); + account.put("id", item.get("id").asLong()); + account.put("name", item.get("name").asText()); + account.put("type", item.has("type") ? item.get("type").asText() : ""); + result.add(account); + } + } + return result; + } catch (Exception e) { + log.error("获取DNS账户失败", e); + return Collections.emptyList(); + } + } + + @Override + public List> getWebsites(Long serverId) { + PlatformServer server = serverService.getById(serverId); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return Collections.emptyList(); + } + + try { + String response = sendGet(server, "/websites/list"); + + if (response == null) { + log.warn("获取网站列表失败: 服务器{}未返回数据", serverId); + return Collections.emptyList(); + } + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + List> result = new ArrayList<>(); + + if (data != null && data.isArray()) { + for (JsonNode item : data) { + Map website = new HashMap<>(); + website.put("id", item.get("id").asLong()); + website.put("primaryDomain", item.get("primaryDomain").asText()); + website.put("alias", item.has("alias") ? item.get("alias").asText() : ""); + result.add(website); + } + } + return result; + } catch (Exception e) { + log.error("获取网站列表失败", e); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/PlatformDomainServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/PlatformDomainServiceImpl.java new file mode 100644 index 0000000..51c6e6c --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/PlatformDomainServiceImpl.java @@ -0,0 +1,1064 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.dto.DomainDeployRequest; +import com.nanxiislet.admin.dto.DomainDeployResult; +import com.nanxiislet.admin.dto.DomainStatsDTO; +import com.nanxiislet.admin.entity.PlatformDomain; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.mapper.PlatformDomainMapper; +import com.nanxiislet.admin.service.PlatformDomainService; +import com.nanxiislet.admin.service.PlatformCertificateService; +import com.nanxiislet.admin.entity.PlatformCertificate; +import com.nanxiislet.admin.service.PlatformServerService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.net.InetAddress; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +import com.nanxiislet.admin.dto.DomainQueryDTO; + +/** + * 域名管理服务实现 + * + * @author NanxiIslet + * @since 2026-01-13 + */ +@Slf4j +@Service +public class PlatformDomainServiceImpl extends ServiceImpl implements PlatformDomainService { + + @Resource + private PlatformServerService serverService; + + @Resource + private PlatformCertificateService certificateService; + + @Resource + private ObjectMapper objectMapper; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public Page listPage(DomainQueryDTO query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.like(PlatformDomain::getDomain, query.getKeyword()); + } + + if (query.getServerId() != null) { + wrapper.eq(PlatformDomain::getServerId, query.getServerId()); + } + + if (StrUtil.isNotBlank(query.getStatus())) { + wrapper.eq(PlatformDomain::getStatus, query.getStatus()); + } + + if (StrUtil.isNotBlank(query.getSslStatus())) { + wrapper.eq(PlatformDomain::getSslStatus, query.getSslStatus()); + } + + wrapper.orderByDesc(PlatformDomain::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public DomainDeployResult deployDomain(DomainDeployRequest request) { + DomainDeployResult result = DomainDeployResult.success("部署流程开始"); + + try { + // 1. 获取域名信息 + PlatformDomain domain = getById(request.getDomainId()); + if (domain == null) { + return DomainDeployResult.failed("域名不存在"); + } + + // 2. 获取服务器信息 + if (domain.getServerId() == null) { + return DomainDeployResult.failed("域名未绑定服务器"); + } + + PlatformServer server = serverService.getById(domain.getServerId()); + if (server == null) { + return DomainDeployResult.failed("服务器不存在"); + } + + // 3. 检查服务器1Panel配置 + if (!StringUtils.hasText(server.getPanelApiKey())) { + return DomainDeployResult.failed("服务器未配置1Panel API密钥"); + } + + // 更新域名状态为部署中 + domain.setDeployStatus("deploying"); + domain.setLastDeployTime(LocalDateTime.now()); + updateById(domain); + + // 4. 检查/创建网站 + Long websiteId = checkOrCreateWebsite(server, domain, request, result); + if (websiteId == null && !result.getSuccess()) { + updateDomainDeployStatus(domain, "failed", result.getMessage()); + return result; + } + result.setWebsiteId(websiteId); + domain.setPanelWebsiteId(websiteId); + + // 5. 如果启用HTTPS,检查/申请证书 + if (Boolean.TRUE.equals(request.getEnableHttps())) { + Long sslId = checkOrApplyCertificate(server, domain, request, result); + result.setSslCertificateId(sslId); + domain.setPanelSslId(sslId); + + // 6. 配置HTTPS + if (sslId != null && websiteId != null) { + configureWebsiteHttps(server, websiteId, sslId, result); + domain.setEnableHttps(true); + domain.setSslStatus("valid"); + } + } else { + result.addSkippedStep("配置HTTPS", "未启用HTTPS"); + } + + // 更新域名信息 + domain.setStatus("active"); + domain.setDeployStatus("deployed"); + domain.setLastDeployMessage("部署成功"); + updateById(domain); + + result.setSuccess(true); + result.setMessage("部署流程完成"); + return result; + + } catch (Exception e) { + log.error("部署域名失败", e); + result.setSuccess(false); + result.setMessage("部署失败: " + e.getMessage()); + result.addFailedStep("异常", e.getMessage()); + return result; + } + } + + /** + * 更新域名部署状态 + */ + private void updateDomainDeployStatus(PlatformDomain domain, String status, String message) { + domain.setDeployStatus(status); + domain.setLastDeployMessage(message); + updateById(domain); + } + + /** + * 生成1Panel Token + */ + private String generateToken(String apiKey, long timestamp) { + String rawToken = "1panel" + apiKey + timestamp; + return DigestUtils.md5DigestAsHex(rawToken.getBytes()); + } + + /** + * 创建HTTP请求头 + */ + private HttpHeaders createHeaders(PlatformServer server) { + long timestamp = System.currentTimeMillis() / 1000; + String token = generateToken(server.getPanelApiKey(), timestamp); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("1Panel-Token", token); + headers.set("1Panel-Timestamp", String.valueOf(timestamp)); + return headers; + } + + /** + * 获取1Panel API基础URL + */ + private String getBaseUrl(PlatformServer server) { + if (StringUtils.hasText(server.getPanelUrl())) { + String baseUrl = server.getPanelUrl(); + + // 尝试去除URL中的路径部分(例如安全入口 /super),只保留 协议://IP:端口 + try { + java.net.URI uri = new java.net.URI(baseUrl); + String scheme = uri.getScheme(); + String host = uri.getHost(); + int port = uri.getPort(); + + if (scheme != null && host != null) { + StringBuilder cleanUrl = new StringBuilder(scheme).append("://").append(host); + if (port != -1) { + cleanUrl.append(":").append(port); + } + return cleanUrl.toString(); + } + } catch (Exception e) { + log.warn("解析Panel URL失败: {}", baseUrl, e); + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + return baseUrl; + } + String ip = server.getIp(); + Integer port = server.getPanelPort() != null ? server.getPanelPort() : 42588; + return "http://" + ip + ":" + port; + } + + /** + * 发送POST请求到1Panel + */ + private String sendPost(PlatformServer server, String path, Object body) { + String url = getBaseUrl(server) + path; + HttpHeaders headers = createHeaders(server); + + try { + String jsonBody = body != null ? objectMapper.writeValueAsString(body) : "{}"; + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } catch (Exception e) { + log.error("1Panel POST请求失败: {}", url, e); + throw new RuntimeException("1Panel API请求失败: " + e.getMessage()); + } + } + + /** + * 检查或创建网站 + */ + private Long checkOrCreateWebsite(PlatformServer server, PlatformDomain domain, + DomainDeployRequest request, DomainDeployResult result) { + try { + // 先查找是否已存在该域名的网站 (使用 v2 接口) + String response = sendPost(server, "/api/v2/websites/search", Map.of( + "page", 1, + "pageSize", 100, + "name", domain.getDomain() + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + // v2 接口返回 alias 为域名,primaryDomain可能为空? 检查两者 + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimary = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (domain.getDomain().equals(itemAlias) || domain.getDomain().equals(itemPrimary)) { + Long websiteId = item.get("id").asLong(); + result.addSuccessStep("检查网站", "网站已存在,ID: " + websiteId); + return websiteId; + } + } + } + + // 网站不存在,需要创建 + if (!Boolean.TRUE.equals(request.getCreateIfNotExist())) { + result.setSuccess(false); + result.setMessage("网站不存在,且未设置自动创建"); + result.addFailedStep("检查网站", "网站不存在"); + return null; + } + + // 调用 check 接口 + sendPost(server, "/api/v2/websites/check", new HashMap<>()); + + // 构建创建参数 + Map createParams = new HashMap<>(); + createParams.put("primaryDomain", ""); + createParams.put("alias", domain.getDomain()); + createParams.put("webSiteGroupId", 1); + createParams.put("remark", StringUtils.hasText(domain.getDescription()) ? domain.getDescription() : "域名: " + domain.getDomain()); + createParams.put("otherDomains", ""); + createParams.put("IPV6", false); + createParams.put("enableFtp", false); + createParams.put("ftpUser", ""); + createParams.put("ftpPassword", ""); + + // 数据库默认参数 + createParams.put("createDb", false); + createParams.put("dbType", "mysql"); + createParams.put("dbFormat", "utf8mb4"); + + // 域名列表 + List> domains = new ArrayList<>(); + Map domainItem = new HashMap<>(); + domainItem.put("domain", domain.getDomain()); + domainItem.put("host", domain.getDomain()); + domainItem.put("port", domain.getPort() != null ? domain.getPort() : 80); + domainItem.put("ssl", false); + domains.add(domainItem); + createParams.put("domains", domains); + + // AppInstall 默认参数 + Map appInstall = new HashMap<>(); + appInstall.put("appId", 0); + appInstall.put("name", ""); + appInstall.put("params", new HashMap<>()); + createParams.put("appinstall", appInstall); + + // 根据是否是反向代理设置类型 + if (StringUtils.hasText(domain.getProxyPass())) { + createParams.put("type", "proxy"); + createParams.put("appType", "installed"); + createParams.put("proxy", domain.getProxyPass()); + createParams.put("proxyType", "tcp"); + + String proxyPass = domain.getProxyPass(); + if (proxyPass.contains("://")) { + String[] parts = proxyPass.split("://"); + createParams.put("proxyProtocol", parts[0] + "://"); + createParams.put("proxyAddress", parts[1]); + } else { + createParams.put("proxyProtocol", "http://"); + createParams.put("proxyAddress", proxyPass); + } + + // 运行环境类型,反代时似乎也需要传,参考用户提供的 json + createParams.put("runtimeType", "php"); + } else { + createParams.put("type", "static"); + createParams.put("appType", "installed"); + createParams.put("runtimeType", "php"); // 静态网站默认 + + String sitePath = domain.getSitePath(); + if (!StringUtils.hasText(sitePath)) { + // sitePath = "/opt/1panel/www/sites/" + domain.getDomain() + "/index"; + sitePath = ""; // 留空让面板自动生成 + } + createParams.put("siteDir", sitePath); + } + + // Root port parameter (important for some 1Panel versions/types) + if (domain.getPort() != null) { + createParams.put("port", domain.getPort()); + } else { + createParams.put("port", 80); + } + + // SSL 配置 (如果在创建时就启用) + // 如果 domain 中已经关联了 certificateId, 我们尝试在创建时就绑定 + if (domain.getCertificateId() != null) { + PlatformCertificate cert = certificateService.getById(domain.getCertificateId()); + if (cert != null && cert.getPanelSslId() != null) { + createParams.put("enableSSL", true); + createParams.put("websiteSSLID", cert.getPanelSslId()); + + if (cert.getAcmeAccountId() != null) { + createParams.put("acmeAccountID", cert.getAcmeAccountId()); + } + + result.addSuccessStep("SSL配置", "将绑定证书: " + cert.getPrimaryDomain()); + } else { + createParams.put("enableSSL", false); + } + } else { + createParams.put("enableSSL", false); + } + + String createResponse = sendPost(server, "/api/v2/websites", createParams); + JsonNode createResult = objectMapper.readTree(createResponse); + + if (createResult.has("code") && createResult.get("code").asInt() == 200) { + // 重新查询获取网站ID (增加重试机制,防止数据延迟) + for (int i = 0; i < 3; i++) { + try { + if (i > 0) Thread.sleep(1000); // 等待1秒 + + response = sendPost(server, "/api/v2/websites/search", Map.of( + "page", 1, + "pageSize", 20, + "name", domain.getDomain(), + "orderBy", "created_at", + "order", "descending" + )); + jsonNode = objectMapper.readTree(response); + data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimary = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (domain.getDomain().equals(itemAlias) || domain.getDomain().equals(itemPrimary)) { + Long websiteId = item.get("id").asLong(); + result.addSuccessStep("创建网站", "网站创建成功,ID: " + websiteId); + return websiteId; + } + } + } + } catch (Exception e) { + log.warn("查询新创建网站失败(第{}次): {}", i+1, e.getMessage()); + } + } + // 如果循环结束还没找到,说明虽然创建接口返回成功,但查不到 + log.error("网站创建接口返回成功,但无法查询到该网站: {}", domain.getDomain()); + } + + result.addFailedStep("创建网站", "网站创建失败: " + (createResult.has("message") ? createResult.get("message").asText() : "未知错误")); + result.setSuccess(false); + result.setMessage("网站创建失败"); + return null; + + } catch (Exception e) { + log.error("检查/创建网站失败", e); + result.addFailedStep("检查网站", e.getMessage()); + result.setSuccess(false); + result.setMessage("检查网站失败: " + e.getMessage()); + return null; + } + } + + /** + * 检查或申请证书 + */ + private Long checkOrApplyCertificate(PlatformServer server, PlatformDomain domain, + DomainDeployRequest request, DomainDeployResult result) { + try { + // 先查找是否已存在该域名的证书 + String response = sendPost(server, "/api/v1/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String primaryDomain = item.get("primaryDomain").asText(); + if (domain.getDomain().equals(primaryDomain)) { + Long sslId = item.get("id").asLong(); + // 获取过期时间 + if (item.has("expireDate")) { + String expireDate = item.get("expireDate").asText(); + if (expireDate.length() >= 10) { + domain.setSslExpireDate(LocalDate.parse(expireDate.substring(0, 10))); + } + } + result.addSuccessStep("检查SSL证书", "证书已存在,ID: " + sslId); + return sslId; + } + } + } + + // 证书不存在,需要申请 + if (request.getAcmeAccountId() == null || request.getDnsAccountId() == null) { + result.addSkippedStep("申请SSL证书", "未配置Acme/DNS账户"); + return null; + } + + // 申请证书 + Map applyParams = new HashMap<>(); + applyParams.put("primaryDomain", domain.getDomain()); + applyParams.put("otherDomains", ""); + applyParams.put("provider", "dnsAccount"); + applyParams.put("acmeAccountId", request.getAcmeAccountId()); + applyParams.put("dnsAccountId", request.getDnsAccountId()); + applyParams.put("autoRenew", true); + applyParams.put("keyType", "P256"); + applyParams.put("apply", true); + + sendPost(server, "/api/v1/websites/ssl", applyParams); + + // 等待证书申请完成(最多等待120秒) + for (int i = 0; i < 24; i++) { + Thread.sleep(5000); + + response = sendPost(server, "/api/v1/websites/ssl/search", Map.of( + "page", 1, + "pageSize", 100 + )); + + jsonNode = objectMapper.readTree(response); + data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + String primaryDomain = item.get("primaryDomain").asText(); + if (domain.getDomain().equals(primaryDomain)) { + Long sslId = item.get("id").asLong(); + result.addSuccessStep("申请SSL证书", "证书申请成功,ID: " + sslId); + return sslId; + } + } + } + } + + result.addFailedStep("申请SSL证书", "证书申请超时"); + return null; + + } catch (Exception e) { + log.error("检查/申请证书失败", e); + result.addFailedStep("检查SSL证书", e.getMessage()); + return null; + } + } + + /** + * 配置网站HTTPS + */ + private void configureWebsiteHttps(PlatformServer server, Long websiteId, Long sslId, DomainDeployResult result) { + try { + Map httpsParams = new HashMap<>(); + httpsParams.put("enable", true); + httpsParams.put("type", "select"); + httpsParams.put("websiteSSLId", sslId); + httpsParams.put("httpConfig", "HTTPToHTTPS"); + httpsParams.put("SSLProtocol", new String[]{"TLSv1.2", "TLSv1.3"}); + + sendPost(server, "/api/v1/websites/" + websiteId + "/https", httpsParams); + result.addSuccessStep("配置HTTPS", "HTTPS配置成功"); + + } catch (Exception e) { + log.error("配置HTTPS失败", e); + result.addFailedStep("配置HTTPS", e.getMessage()); + } + } + + @Override + public Map checkDomainDns(Long domainId) { + Map result = new HashMap<>(); + result.put("resolved", false); + + PlatformDomain domain = getById(domainId); + if (domain == null) { + result.put("error", "域名不存在"); + return result; + } + + try { + // 尝试解析域名 + InetAddress[] addresses = InetAddress.getAllByName(domain.getDomain()); + if (addresses != null && addresses.length > 0) { + result.put("resolved", true); + result.put("records", java.util.Arrays.stream(addresses) + .map(InetAddress::getHostAddress) + .toList()); + + // 更新域名DNS状态 + domain.setDnsStatus("resolved"); + updateById(domain); + } else { + domain.setDnsStatus("unresolved"); + updateById(domain); + } + } catch (Exception e) { + log.warn("DNS解析失败: {}", domain.getDomain(), e); + domain.setDnsStatus("unresolved"); + updateById(domain); + result.put("error", e.getMessage()); + } + + return result; + } + + @Override + public PlatformDomain syncDomainFromPanel(Long domainId) { + PlatformDomain domain = getById(domainId); + if (domain == null || domain.getServerId() == null) { + return domain; + } + + PlatformServer server = serverService.getById(domain.getServerId()); + if (server == null || !StringUtils.hasText(server.getPanelApiKey())) { + return domain; + } + + try { + // 查询1Panel中的网站信息 + String response = sendPost(server, "/api/v1/websites/search", Map.of( + "page", 1, + "pageSize", 100, + "name", domain.getDomain() + )); + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + if (data != null && data.has("items")) { + JsonNode items = data.get("items"); + for (JsonNode item : items) { + if (domain.getDomain().equals(item.get("primaryDomain").asText())) { + domain.setPanelWebsiteId(item.get("id").asLong()); + domain.setDeployStatus("deployed"); + domain.setStatus("active"); + + // 检查SSL状态 + if (item.has("sslStatus") && "enable".equals(item.get("sslStatus").asText())) { + domain.setEnableHttps(true); + domain.setSslStatus("valid"); + } + + updateById(domain); + break; + } + } + } + } catch (Exception e) { + log.error("同步域名信息失败", e); + } + + return domain; + } + + @Override + public DomainStatsDTO getDomainStats() { + DomainStatsDTO stats = new DomainStatsDTO(); + + // 总计 + stats.setTotal(count()); + + // 正常 + stats.setActive(count(new LambdaQueryWrapper() + .eq(PlatformDomain::getStatus, "active"))); + + // 待配置 + stats.setPending(count(new LambdaQueryWrapper() + .eq(PlatformDomain::getStatus, "pending"))); + + // SSL即将过期(30天内) + LocalDate expiringDate = LocalDate.now().plusDays(30); + stats.setSslExpiring(count(new LambdaQueryWrapper() + .eq(PlatformDomain::getSslStatus, "expiring") + .or() + .le(PlatformDomain::getSslExpireDate, expiringDate) + .gt(PlatformDomain::getSslExpireDate, LocalDate.now()))); + + // 已部署 + stats.setDeployed(count(new LambdaQueryWrapper() + .eq(PlatformDomain::getDeployStatus, "deployed"))); + + return stats; + } + + @Override + public PlatformDomain getByDomain(String domain) { + return getOne(new LambdaQueryWrapper() + .eq(PlatformDomain::getDomain, domain)); + } + + + + @Override + public int syncDomainsFromCertificates(Long serverId) { + // 获取服务器信息 + PlatformServer server = serverService.getById(serverId); + if (server == null) { + log.warn("服务器不存在: {}", serverId); + return 0; + } + + // 获取该服务器的所有证书 + var certList = certificateService.list( + new LambdaQueryWrapper() + .eq(com.nanxiislet.admin.entity.PlatformCertificate::getServerId, serverId) + ); + + int syncCount = 0; + + for (var cert : certList) { + String primaryDomain = cert.getPrimaryDomain(); + if (StrUtil.isBlank(primaryDomain)) continue; + + // 检查主域名是否已存在(包含已删除) + PlatformDomain existingDomain = baseMapper.findByDomainIncludeDeleted(primaryDomain); + if (existingDomain == null) { + // 创建新域名记录 + PlatformDomain newDomain = new PlatformDomain(); + newDomain.setDomain(primaryDomain); + newDomain.setServerId(serverId); + newDomain.setServerName(server.getName()); + newDomain.setServerIp(server.getIp()); + newDomain.setStatus("active"); + newDomain.setDnsStatus("resolved"); + newDomain.setSslStatus("valid"); + newDomain.setEnableHttps(true); + newDomain.setSslExpireDate(cert.getExpireDate()); + newDomain.setPort(443); + newDomain.setDescription("从证书同步: " + primaryDomain); + save(newDomain); + syncCount++; + log.info("从证书同步域名(新增): {}", primaryDomain); + } else { + // 如果已删除,恢复它 + if (existingDomain.getDeleted() != null && existingDomain.getDeleted() == 1) { + baseMapper.restoreById(existingDomain.getId()); + existingDomain.setDeleted(0); + log.info("从证书同步域名(恢复): {}", primaryDomain); + } + + // 更新关联的服务器信息,确保归属正确 + existingDomain.setServerId(serverId); + existingDomain.setServerName(server.getName()); + existingDomain.setServerIp(server.getIp()); + + // 更新SSL信息 + existingDomain.setSslExpireDate(cert.getExpireDate()); + existingDomain.setSslStatus("valid"); + existingDomain.setEnableHttps(true); // 确保开启HTTPS + + updateById(existingDomain); + syncCount++; + log.info("从证书同步域名(更新): {}", primaryDomain); + } + + // 处理其他域名 + String otherDomains = cert.getOtherDomains(); + if (StrUtil.isNotBlank(otherDomains)) { + for (String otherDomain : otherDomains.split(",")) { + otherDomain = otherDomain.trim(); + if (StrUtil.isBlank(otherDomain)) continue; + + PlatformDomain existingOther = baseMapper.findByDomainIncludeDeleted(otherDomain); + if (existingOther == null) { + PlatformDomain newDomain = new PlatformDomain(); + newDomain.setDomain(otherDomain); + newDomain.setServerId(serverId); + newDomain.setServerName(server.getName()); + newDomain.setServerIp(server.getIp()); + newDomain.setStatus("active"); + newDomain.setDnsStatus("resolved"); + newDomain.setSslStatus("valid"); + newDomain.setEnableHttps(true); + newDomain.setSslExpireDate(cert.getExpireDate()); + newDomain.setPort(443); + newDomain.setDescription("从证书同步: " + otherDomain); + save(newDomain); + syncCount++; + log.info("从证书同步域名(其他-新增): {}", otherDomain); + } else { + // 如果已删除,恢复它 + if (existingOther.getDeleted() != null && existingOther.getDeleted() == 1) { + baseMapper.restoreById(existingOther.getId()); + existingOther.setDeleted(0); + log.info("从证书同步域名(其他-恢复): {}", otherDomain); + } + + existingOther.setServerId(serverId); + existingOther.setSslExpireDate(cert.getExpireDate()); + existingOther.setSslStatus("valid"); + existingOther.setEnableHttps(true); + updateById(existingOther); + syncCount++; + log.info("从证书同步域名(其他-更新): {}", otherDomain); + } + } + } + } + + return syncCount; + } + + @Override + public void undeployDomain(Long domainId) { + PlatformDomain domain = getById(domainId); + if (domain == null) { + throw new RuntimeException("域名不存在"); + } + + if (domain.getServerId() == null) { + throw new RuntimeException("域名未绑定服务器"); + } + + PlatformServer server = serverService.getById(domain.getServerId()); + if (server == null) { + throw new RuntimeException("服务器已删除"); + } + + Long websiteId = domain.getPanelWebsiteId(); + // 如果本地没有ID,尝试去搜索一下,防止确实存在但没关联 + if (websiteId == null) { + try { + String response = sendPost(server, "/api/v2/websites/search", Map.of( + "page", 1, "pageSize", 10, "name", domain.getDomain() + )); + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("data") && jsonNode.get("data").has("items")) { + for (JsonNode item : jsonNode.get("data").get("items")) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + String itemPrimary = item.has("primaryDomain") ? item.get("primaryDomain").asText() : ""; + if (domain.getDomain().equals(itemAlias) || domain.getDomain().equals(itemPrimary)) { + websiteId = item.get("id").asLong(); + break; + } + } + } + } catch (Exception e) { + log.warn("搜索网站失败", e); + } + } + + if (websiteId != null) { + try { + // 调用删除接口 + Map payload = new HashMap<>(); + payload.put("id", websiteId); + payload.put("deleteApp", false); + payload.put("deleteBackup", true); + payload.put("forceDelete", true); + payload.put("deleteDB", false); + + String res = sendPost(server, "/api/v2/websites/del", payload); + JsonNode resNode = objectMapper.readTree(res); + if (resNode.has("code") && resNode.get("code").asInt() != 200) { + throw new RuntimeException("删除失败: " + (resNode.has("message") ? resNode.get("message").asText() : "未知错误")); + } + } catch (Exception e) { + log.error("在1Panel删除网站失败", e); + throw new RuntimeException("删除1Panel网站失败: " + e.getMessage()); + } + } else { + // log.info("本地和面板均未找到网站ID,仅清除本地状态"); + } + + // 更新本地状态 + domain.setDeployStatus("not_deployed"); + domain.setRuntimeDeployStatus("not_deployed"); + domain.setPanelWebsiteId(null); + domain.setLastDeployMessage("已删除部署"); + // 保留 proxyPass 配置,以便用户重新部署 + + updateById(domain); + } + + @Override + public DomainDeployResult deployRuntime(Long domainId) { + DomainDeployResult result = DomainDeployResult.success("开始部署运行环境"); + + try { + // 1. 获取域名信息 + PlatformDomain domain = getById(domainId); + if (domain == null) { + return DomainDeployResult.failed("域名不存在"); + } + + // 2. 检查运行环境配置 + if (domain.getRuntimeId() == null) { + return DomainDeployResult.failed("未配置运行环境"); + } + if (!StringUtils.hasText(domain.getRuntimeType())) { + return DomainDeployResult.failed("未配置运行环境类型"); + } + + // 3. 获取服务器信息 + if (domain.getServerId() == null) { + return DomainDeployResult.failed("域名未绑定服务器"); + } + + PlatformServer server = serverService.getById(domain.getServerId()); + if (server == null) { + return DomainDeployResult.failed("服务器不存在"); + } + + if (!StringUtils.hasText(server.getPanelApiKey())) { + return DomainDeployResult.failed("服务器未配置1Panel API密钥"); + } + + // 4. 更新域名状态为部署中 + domain.setRuntimeDeployStatus("deploying"); + updateById(domain); + + // 5. 构建创建运行环境网站的请求参数 + Map createParams = new HashMap<>(); + createParams.put("primaryDomain", ""); + createParams.put("type", "runtime"); + createParams.put("alias", domain.getDomain()); + createParams.put("remark", StringUtils.hasText(domain.getDescription()) ? domain.getDescription() : "运行环境: " + domain.getRuntimeName()); + createParams.put("appType", "installed"); + createParams.put("webSiteGroupId", 1); + createParams.put("otherDomains", ""); + createParams.put("proxy", ""); + createParams.put("runtimeID", domain.getRuntimeId().intValue()); + createParams.put("runtimeType", domain.getRuntimeType()); + createParams.put("IPV6", false); + createParams.put("enableFtp", false); + createParams.put("ftpUser", ""); + createParams.put("ftpPassword", ""); + createParams.put("proxyType", "tcp"); + + // 关键修正:对于 runtime 类型,顶层 port 参数是后端应用监听的内部端口 + // 我们从 proxyPass (http://127.0.0.1:8080) 中解析出 8080 + int internalPort = 8080; // 默认回退值 + if (StringUtils.hasText(domain.getProxyPass())) { + try { + String proxyPass = domain.getProxyPass(); + if (proxyPass.contains("://")) { + java.net.URI uri = new java.net.URI(proxyPass); + if (uri.getPort() != -1) { + internalPort = uri.getPort(); + } + } else if (proxyPass.contains(":")) { + // 处理没有协议头的情况 (e.g. 127.0.0.1:8080) + String[] parts = proxyPass.split(":"); + internalPort = Integer.parseInt(parts[parts.length - 1]); + } + } catch (Exception e) { + log.warn("解析内部端口失败: {}, 使用默认端口 {}", domain.getProxyPass(), internalPort); + } + } + createParams.put("port", internalPort); + + createParams.put("proxyProtocol", "http://"); + createParams.put("proxyAddress", ""); + createParams.put("siteDir", ""); + createParams.put("streamPorts", ""); + createParams.put("udp", false); + createParams.put("name", ""); + createParams.put("algorithm", ""); + createParams.put("servers", new ArrayList<>()); + + // AppInstall 参数 + Map appInstall = new HashMap<>(); + appInstall.put("appId", 0); + appInstall.put("name", ""); + appInstall.put("appDetailId", 0); + appInstall.put("params", new HashMap<>()); + appInstall.put("version", ""); + appInstall.put("appkey", ""); + appInstall.put("advanced", false); + appInstall.put("cpuQuota", 0); + appInstall.put("memoryLimit", 0); + appInstall.put("memoryUnit", "MB"); + appInstall.put("containerName", ""); + appInstall.put("allowPort", false); + appInstall.put("format", "utf8mb4"); + appInstall.put("collation", ""); + createParams.put("appinstall", appInstall); + + // 数据库参数 + createParams.put("createDb", false); + createParams.put("dbName", ""); + createParams.put("dbPassword", ""); + createParams.put("dbFormat", "utf8mb4"); + createParams.put("dbUser", ""); + createParams.put("dbType", "mysql"); + createParams.put("dbHost", ""); + + // 域名列表 + List> domains = new ArrayList<>(); + Map domainItem = new HashMap<>(); + domainItem.put("domain", domain.getDomain()); + domainItem.put("host", domain.getDomain()); + domainItem.put("port", domain.getPort() != null ? domain.getPort() : 80); + domainItem.put("ssl", false); + domains.add(domainItem); + createParams.put("domains", domains); + + // 6. 处理 SSL 配置 + if (Boolean.TRUE.equals(domain.getEnableHttps()) && domain.getCertificateId() != null) { + PlatformCertificate cert = certificateService.getById(domain.getCertificateId()); + if (cert != null && cert.getPanelSslId() != null) { + createParams.put("enableSSL", true); + createParams.put("websiteSSLID", cert.getPanelSslId().intValue()); + if (cert.getAcmeAccountId() != null) { + createParams.put("acmeAccountID", cert.getAcmeAccountId().intValue()); + } + result.addSuccessStep("SSL配置", "将绑定证书: " + cert.getPrimaryDomain()); + } else { + createParams.put("enableSSL", false); + createParams.put("websiteSSLID", 0); + createParams.put("acmeAccountID", 0); + } + } else { + createParams.put("enableSSL", false); + createParams.put("websiteSSLID", 0); + createParams.put("acmeAccountID", 0); + } + + // 7. 生成 taskID + createParams.put("taskID", java.util.UUID.randomUUID().toString()); + + // 8. 调用 1Panel API 创建网站 + log.info("部署运行环境请求参数: {}", objectMapper.writeValueAsString(createParams)); + String createResponse = sendPost(server, "/api/v2/websites", createParams); + JsonNode createResult = objectMapper.readTree(createResponse); + + if (createResult.has("code") && createResult.get("code").asInt() == 200) { + result.addSuccessStep("创建运行环境网站", "请求已提交"); + + // 等待并查询网站ID + Thread.sleep(2000); + String searchResponse = sendPost(server, "/api/v2/websites/search", Map.of( + "page", 1, + "pageSize", 20, + "name", domain.getDomain(), + "orderBy", "created_at", + "order", "descending" + )); + JsonNode searchResult = objectMapper.readTree(searchResponse); + JsonNode data = searchResult.get("data"); + if (data != null && data.has("items")) { + for (JsonNode item : data.get("items")) { + String itemAlias = item.has("alias") ? item.get("alias").asText() : ""; + if (domain.getDomain().equals(itemAlias)) { + Long websiteId = item.get("id").asLong(); + domain.setPanelWebsiteId(websiteId); + result.setWebsiteId(websiteId); + result.addSuccessStep("获取网站ID", "网站ID: " + websiteId); + break; + } + } + } + + // 更新域名状态 + domain.setRuntimeDeployStatus("deployed"); + domain.setDeployStatus("deployed"); + domain.setStatus("active"); + domain.setLastDeployTime(java.time.LocalDateTime.now()); + domain.setLastDeployMessage("运行环境部署成功"); + updateById(domain); + + result.setSuccess(true); + result.setMessage("运行环境部署成功"); + } else { + String errorMsg = createResult.has("message") ? createResult.get("message").asText() : "未知错误"; + result.addFailedStep("创建运行环境网站", errorMsg); + result.setSuccess(false); + result.setMessage("部署失败: " + errorMsg); + + domain.setRuntimeDeployStatus("failed"); + domain.setLastDeployMessage("部署失败: " + errorMsg); + updateById(domain); + } + + return result; + + } catch (Exception e) { + log.error("部署运行环境失败", e); + result.setSuccess(false); + result.setMessage("部署失败: " + e.getMessage()); + result.addFailedStep("异常", e.getMessage()); + + // 更新状态为失败 + try { + PlatformDomain domain = getById(domainId); + if (domain != null) { + domain.setRuntimeDeployStatus("failed"); + domain.setLastDeployMessage("部署异常: " + e.getMessage()); + updateById(domain); + } + } catch (Exception ignored) {} + + return result; + } + } + +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/PlatformProjectServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/PlatformProjectServiceImpl.java new file mode 100644 index 0000000..bb26c23 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/PlatformProjectServiceImpl.java @@ -0,0 +1,37 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.entity.PlatformProject; +import com.nanxiislet.admin.mapper.PlatformProjectMapper; +import com.nanxiislet.admin.service.PlatformProjectService; +import org.springframework.stereotype.Service; + +/** + * 项目管理服务实现 + */ +@Service +public class PlatformProjectServiceImpl extends ServiceImpl implements PlatformProjectService { + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(PlatformProject::getName, query.getKeyword()) + .or().like(PlatformProject::getCode, query.getKeyword()) + ); + } + + wrapper.orderByAsc(PlatformProject::getSort) + .orderByDesc(PlatformProject::getCreatedAt); + + return page(page, wrapper); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/PlatformServerServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/PlatformServerServiceImpl.java new file mode 100644 index 0000000..8a901e4 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/PlatformServerServiceImpl.java @@ -0,0 +1,419 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanxiislet.admin.common.base.BasePageQuery; +import com.nanxiislet.admin.dto.ServerInfoDto; +import com.nanxiislet.admin.entity.PlatformServer; +import com.nanxiislet.admin.mapper.PlatformServerMapper; +import com.nanxiislet.admin.service.PlatformServerService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** + * 服务器管理服务实现 + */ +@Slf4j +@Service +public class PlatformServerServiceImpl extends ServiceImpl implements PlatformServerService { + + @Resource + private ObjectMapper objectMapper; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public Page listPage(BasePageQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(PlatformServer::getName, query.getKeyword()) + .or().like(PlatformServer::getIp, query.getKeyword()) + ); + } + + wrapper.orderByDesc(PlatformServer::getCreatedAt); + + return page(page, wrapper); + } + + @Override + public ServerInfoDto getServerStatus(Long id) { + ServerInfoDto dto = new ServerInfoDto(); + PlatformServer server = getById(id); + if (server == null) { + return dto; + } + + // 1. 基础信息转换 + dto = convertToDto(server); + + // 2. 只有配置了 Panel信 息才去获取实时状态 + if (StrUtil.isBlank(server.getPanelUrl()) || StrUtil.isBlank(server.getPanelApiKey())) { + dto.setStatus("offline"); + return dto; + } + + try { + // 根据 Swagger 文档修正:使用 GET /dashboard/current/:ioOption/:netOption + // 而不是 POST + String response = sendGet1Panel(server, "/dashboard/current/all/all"); + + if (response == null) { + // 请求失败 + return dto; + } + + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode data = jsonNode.get("data"); + + if (data != null) { + dto.setStatus("online"); + fillRealtimeData(dto, data); + + // 补充系统信息:务必尝试 /hosts/info 以获取准确的 OS 和内核版本 + // /dashboard/current 可能不包含详细 OS 信息 + try { + String hostInfoRes = sendGet1Panel(server, "/hosts/info"); + if (hostInfoRes != null) { + JsonNode hostData = objectMapper.readTree(hostInfoRes).get("data"); + if (hostData != null) { + if (hostData.has("os")) dto.setOs(hostData.get("os").asText()); + else if (hostData.has("platform")) dto.setOs(hostData.get("platform").asText()); + } + } + } catch (Exception ignore) {} + + // 更新DB状态 + updateServerStatus(server, dto); + } + } catch (Exception e) { + log.error("解析1Panel状态失败: {}", e.getMessage()); + } + + return dto; + } + + private double round2(double value) { + return Math.round(value * 100.0) / 100.0; + } + + private void fillRealtimeData(ServerInfoDto dto, JsonNode data) { + // CPU + if (data.has("cpuUsedPercent")) dto.getCpu().setUsage(round2(data.get("cpuUsedPercent").asDouble())); + else if (data.has("cpu")) dto.getCpu().setUsage(round2(data.get("cpu").asDouble())); + else if (data.has("cpuPercent")) { // 有些版本是 cpuPercent (array) + JsonNode cp = data.get("cpuPercent"); + if (cp.isArray() && cp.size() > 0) dto.getCpu().setUsage(round2(cp.get(0).asDouble())); + } + + if (data.has("cpuTotal")) dto.getCpu().setCores(data.get("cpuTotal").asInt()); + else if (data.has("cpuCores")) dto.getCpu().setCores(data.get("cpuCores").asInt()); + + // Memory + if (data.has("memoryUsed")) { + double usedBytes = data.get("memoryUsed").asDouble(); + dto.getMemory().setUsed(round2(usedBytes / (1024 * 1024 * 1024))); + } + if (data.has("memoryTotal")) { + double totalBytes = data.get("memoryTotal").asDouble(); + dto.getMemory().setTotal(round2(totalBytes / (1024 * 1024 * 1024))); + } + // 计算内存使用率 + if (dto.getMemory().getTotal() > 0.0) { + dto.getMemory().setUsage(round2(dto.getMemory().getUsed() / dto.getMemory().getTotal() * 100)); + } + + // Disk (优先从 diskData 数组中汇总,旧版本可能是 disk) + boolean diskParsed = false; + JsonNode diskNode = data.has("diskData") ? data.get("diskData") : (data.has("disk") ? data.get("disk") : null); + + if (diskNode != null && diskNode.isArray()) { + double totalDisk = 0; + double usedDisk = 0; + for (JsonNode d : diskNode) { + if (d.has("total")) totalDisk += d.get("total").asDouble(); + if (d.has("used")) usedDisk += d.get("used").asDouble(); + } + if (totalDisk > 0) { + dto.getDisk().setTotal(round2(totalDisk / (1024 * 1024 * 1024))); + dto.getDisk().setUsed(round2(usedDisk / (1024 * 1024 * 1024))); + diskParsed = true; + } + } + + // 如果数组解析失败,尝试直接字段 + if (!diskParsed) { + if (data.has("diskUsed")) { + double usedBytes = data.get("diskUsed").asDouble(); + dto.getDisk().setUsed(round2(usedBytes / (1024 * 1024 * 1024))); + } + if (data.has("diskTotal")) { + double totalBytes = data.get("diskTotal").asDouble(); + dto.getDisk().setTotal(round2(totalBytes / (1024 * 1024 * 1024))); + } + } + // 计算磁盘使用率 + if (dto.getDisk().getTotal() > 0.0) { + dto.getDisk().setUsage(round2(dto.getDisk().getUsed() / dto.getDisk().getTotal() * 100)); + } + + // OS + if (data.has("os")) dto.setOs(data.get("os").asText()); + else if (data.has("platform")) dto.setOs(data.get("platform").asText()); + } + + /** + * 将实体转换为DTO + */ + private ServerInfoDto convertToDto(PlatformServer server) { + List tags = new ArrayList<>(); + if (StringUtils.hasText(server.getTags())) { + try { + tags = objectMapper.readValue(server.getTags(), new TypeReference>() {}); + } catch (Exception e) { + log.warn("解析服务器标签失败: {}", server.getTags()); + } + } + + return ServerInfoDto.builder() + .id(server.getId()) + .name(server.getName()) + .ip(server.getIp()) + .internalIp(server.getInternalIp()) + .port(server.getPort()) + .type(server.getType()) + .status(server.getStatus()) + .os(server.getOs()) + .tags(tags) + + .panelUrl(server.getPanelUrl()) + .panelPort(server.getPanelPort()) + .description(server.getDescription()) + .createdAt(server.getCreatedAt()) + .updatedAt(server.getUpdatedAt()) + .cpu(ServerInfoDto.CpuInfo.builder().cores(server.getCpuCores() != null ? server.getCpuCores() : 0).usage(0.0).build()) + .memory(ServerInfoDto.MemoryInfo.builder().total(server.getMemoryTotal() != null ? server.getMemoryTotal().doubleValue() : 0.0).used(0.0).usage(0.0).build()) + .disk(ServerInfoDto.DiskInfo.builder().total(server.getDiskTotal() != null ? server.getDiskTotal().doubleValue() : 0.0).used(0.0).usage(0.0).build()) + .build(); + } + + /** + * 更新数据库中的服务器状态 + */ + private void updateServerStatus(PlatformServer server, ServerInfoDto dto) { + PlatformServer update = new PlatformServer(); + update.setId(server.getId()); + update.setStatus(dto.getStatus()); + update.setOs(dto.getOs()); + update.setCpuCores(dto.getCpu().getCores()); + update.setMemoryTotal(dto.getMemory().getTotal().intValue()); + update.setDiskTotal(dto.getDisk().getTotal().intValue()); + updateById(update); + } + + /** + * 生成1Panel Token + */ + private String generateToken(String apiKey, long timestamp) { + String rawToken = "1panel" + apiKey + timestamp; + return DigestUtils.md5DigestAsHex(rawToken.getBytes()); + } + + private String sendPost1Panel(PlatformServer server, String path, Object body) { + return doRequest1Panel(server, path, HttpMethod.POST, body); + } + + private String sendGet1Panel(PlatformServer server, String path) { + return doRequest1Panel(server, path, HttpMethod.GET, null); + } + + /** + * 使用 IP 和端口构建 Base URL + */ + private String buildBaseUrlFromIpAndPort(PlatformServer server) { + String protocol = "http"; + String ip = server.getIp(); + Integer port = server.getPanelPort() != null ? server.getPanelPort() : 42588; + return protocol + "://" + ip + ":" + port; + } + + private String doRequest1Panel(PlatformServer server, String path, HttpMethod method, Object body) { + String baseUrl = ""; + String apiPath = server.getPanelUrl(); + String pathPrefix = ""; + + if (StrUtil.isNotBlank(apiPath)) { + if (apiPath.endsWith("/")) apiPath = apiPath.substring(0, apiPath.length() - 1); + + // 如果用户输入的 URL 不包含协议前缀,自动添加 https:// + if (!apiPath.startsWith("http://") && !apiPath.startsWith("https://")) { + apiPath = "https://" + apiPath; + } + + baseUrl = apiPath; + // 提取 path prefix (e.g. /super) + try { + java.net.URI uri = new java.net.URI(baseUrl); + pathPrefix = uri.getPath(); + String scheme = uri.getScheme(); + String host = uri.getHost(); + int port = uri.getPort(); + + // 确保 scheme 和 host 有效 + if (scheme == null || host == null) { + log.warn("Panel URL 解析无效,scheme={}, host={}, 原始URL: {}", scheme, host, apiPath); + // 回退到使用 IP 和端口 + baseUrl = buildBaseUrlFromIpAndPort(server); + } else if (port > 0) { + // URL 中明确指定了端口 + baseUrl = scheme + "://" + host + ":" + port; + } else if (server.getPanelPort() != null && server.getPanelPort() > 0) { + // URL 中没有端口,但用户配置了独立的 panelPort + baseUrl = scheme + "://" + host + ":" + server.getPanelPort(); + } else { + // 使用默认端口(不附加端口号,依赖协议默认端口) + baseUrl = scheme + "://" + host; + } + } catch (Exception e) { + log.warn("解析Panel URL失败: {}", apiPath, e); + // 回退到使用 IP 和端口 + baseUrl = buildBaseUrlFromIpAndPort(server); + } + } else { + baseUrl = buildBaseUrlFromIpAndPort(server); + } + + + + // 构造尝试列表: + // 1. 带前缀 (如果配置了): /super/api/v2 + // 2. 带前缀 v1: /super/api/v1 + // 3. 不带前缀 (标准): /api/v2 <-- 新增 + // 4. 不带前缀 v1: /api/v1 <-- 新增 + + List tryPaths = new ArrayList<>(); + if (StrUtil.isNotBlank(pathPrefix)) { + tryPaths.add(pathPrefix + "/api/v2"); + tryPaths.add(pathPrefix + "/api/v1"); + } + tryPaths.add("/api/v2"); + tryPaths.add("/api/v1"); + + // 去重 + List distinctPaths = tryPaths.stream().distinct().toList(); + + for (String versionPath : distinctPaths) { + String fullUrl = baseUrl + versionPath + path; + + long localTimestamp = System.currentTimeMillis() / 1000; + + ResponseEntity responseEntity = executeRequest(fullUrl, method, body, server.getPanelApiKey(), localTimestamp); + String responseBody = responseEntity.getBody(); + + + boolean isHtml = responseBody != null && responseBody.trim().startsWith("<"); + + if (responseEntity.getStatusCode().is2xxSuccessful() && !isHtml) { + return responseBody; + } + + if (isHtml) { + + // 尝试校准时间 + long serverTimestamp = -1; + long responseDate = responseEntity.getHeaders().getDate(); + if (responseDate > 0) serverTimestamp = responseDate / 1000; + else serverTimestamp = localTimestamp - 31536000; + + if (serverTimestamp > 0) { + ResponseEntity retryResponse = executeRequest(fullUrl, method, body, server.getPanelApiKey(), serverTimestamp); + String retryBody = retryResponse.getBody(); + if (retryBody != null && !retryBody.trim().startsWith("<")) { + return retryBody; + } + } + } + } + return null; + } + + private ResponseEntity executeRequest(String url, HttpMethod method, Object body, String apiKey, long timestamp) { + String token = generateToken(apiKey, timestamp); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("1Panel-Token", token); + headers.set("1Panel-Timestamp", String.valueOf(timestamp)); + headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + HttpEntity entity = new HttpEntity<>(body, headers); + try { + return restTemplate.exchange(url, method, entity, String.class); + } catch (Exception e) { + if (e instanceof org.springframework.web.client.HttpStatusCodeException) { + org.springframework.web.client.HttpStatusCodeException se = (org.springframework.web.client.HttpStatusCodeException) e; + return new ResponseEntity<>(se.getResponseBodyAsString(), se.getResponseHeaders(), se.getStatusCode()); + } + log.error("Request 1Panel Error: {} {}", url, e.getMessage()); + return new ResponseEntity<>(null, org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Resource + private com.nanxiislet.admin.mapper.PlatformProjectMapper projectMapper; + + @Override + public List getBindingProjects(Long serverId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(com.nanxiislet.admin.entity.PlatformProject::getServerId, serverId); + wrapper.select(com.nanxiislet.admin.entity.PlatformProject::getName); + + List projects = projectMapper.selectList(wrapper); + List projectNames = new ArrayList<>(); + for (com.nanxiislet.admin.entity.PlatformProject project : projects) { + projectNames.add(project.getName()); + } + return projectNames; + } + + @Resource + private com.nanxiislet.admin.mapper.PlatformDomainMapper domainMapper; + + @Resource + private com.nanxiislet.admin.mapper.PlatformCertificateMapper certificateMapper; + + @Override + public void deleteBindingDomains(Long serverId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(com.nanxiislet.admin.entity.PlatformDomain::getServerId, serverId); + domainMapper.delete(wrapper); + log.info("已删除服务器 {} 绑定的所有域名", serverId); + } + + @Override + public void deleteBindingCertificates(Long serverId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(com.nanxiislet.admin.entity.PlatformCertificate::getServerId, serverId); + certificateMapper.delete(wrapper); + log.info("已删除服务器 {} 绑定的所有证书", serverId); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalInstanceServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalInstanceServiceImpl.java new file mode 100644 index 0000000..2c394e5 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalInstanceServiceImpl.java @@ -0,0 +1,259 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.dto.approval.ApprovalInstanceVO; +import com.nanxiislet.admin.entity.SysApprovalInstance; +import com.nanxiislet.admin.entity.SysApprovalNode; +import com.nanxiislet.admin.entity.SysApprovalRecord; +import com.nanxiislet.admin.entity.SysApprovalTemplate; +import com.nanxiislet.admin.entity.SysUser; +import com.nanxiislet.admin.mapper.SysApprovalInstanceMapper; +import com.nanxiislet.admin.mapper.SysApprovalNodeMapper; +import com.nanxiislet.admin.mapper.SysApprovalRecordMapper; +import com.nanxiislet.admin.mapper.SysApprovalTemplateMapper; +import com.nanxiislet.admin.mapper.SysUserMapper; +import com.nanxiislet.admin.service.SysApprovalInstanceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 审批实例服务实现 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Slf4j +@Service +public class SysApprovalInstanceServiceImpl extends ServiceImpl + implements SysApprovalInstanceService { + + @Resource + private SysApprovalTemplateMapper templateMapper; + + @Resource + private SysApprovalNodeMapper nodeMapper; + + @Resource + private SysApprovalRecordMapper recordMapper; + + @Resource + private SysUserMapper userMapper; + + @Override + public IPage listPage(Integer page, Integer pageSize, String scenario, String status, String keyword) { + Page pageParam = new Page<>(page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(scenario)) { + wrapper.eq(SysApprovalInstance::getScenario, scenario); + } + if (StringUtils.hasText(status)) { + wrapper.eq(SysApprovalInstance::getStatus, status); + } + if (StringUtils.hasText(keyword)) { + wrapper.and(w -> w + .like(SysApprovalInstance::getBusinessTitle, keyword) + .or() + .like(SysApprovalInstance::getInitiatorName, keyword)); + } + wrapper.orderByDesc(SysApprovalInstance::getCreatedAt); + + IPage result = this.page(pageParam, wrapper); + + // 转换为VO + Page voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); + voPage.setRecords(result.getRecords().stream() + .map(this::toVO) + .collect(Collectors.toList())); + + return voPage; + } + + @Override + public ApprovalInstanceVO getDetail(Long id) { + SysApprovalInstance instance = this.getById(id); + if (instance == null) { + throw new BusinessException("审批实例不存在"); + } + return toVO(instance); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(String businessType, Long businessId, String businessTitle, Long initiatorId) { + // 查找匹配的模板 + SysApprovalTemplate template = templateMapper.selectOne(new LambdaQueryWrapper() + .eq(SysApprovalTemplate::getScenario, businessType) + .eq(SysApprovalTemplate::getEnabled, 1) + .last("LIMIT 1")); + + if (template == null) { + throw new BusinessException("未找到适用的审批流程模板"); + } + + // 获取发起人信息 + SysUser user = userMapper.selectById(initiatorId); + + // 获取第一个节点 + SysApprovalNode firstNode = nodeMapper.selectOne(new LambdaQueryWrapper() + .eq(SysApprovalNode::getTemplateId, template.getId()) + .orderByAsc(SysApprovalNode::getSort) + .last("LIMIT 1")); + + // 创建实例 + SysApprovalInstance instance = new SysApprovalInstance(); + instance.setTemplateId(template.getId()); + instance.setTemplateName(template.getName()); + instance.setScenario(businessType); + instance.setBusinessType(businessType); + instance.setBusinessId(businessId); + instance.setBusinessTitle(businessTitle); + instance.setInitiatorId(initiatorId); + instance.setInitiatorName(user != null ? user.getNickname() : "未知用户"); + instance.setInitiatorAvatar(user != null ? user.getAvatar() : null); + instance.setStatus("pending"); + if (firstNode != null) { + instance.setCurrentNodeId(firstNode.getId()); + instance.setCurrentNodeName(firstNode.getName()); + } + + this.save(instance); + + log.info("创建审批实例: id={}, businessType={}, businessId={}", instance.getId(), businessType, businessId); + return instance.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submit(Long id) { + SysApprovalInstance instance = this.getById(id); + if (instance == null) { + throw new BusinessException("审批实例不存在"); + } + if (!"pending".equals(instance.getStatus())) { + throw new BusinessException("当前状态不允许提交"); + } + + instance.setStatus("in_progress"); + instance.setSubmittedAt(LocalDateTime.now()); + this.updateById(instance); + + log.info("提交审批: id={}", id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void approve(Long id, Long nodeId, Long approverId, String action, String comment) { + SysApprovalInstance instance = this.getById(id); + if (instance == null) { + throw new BusinessException("审批实例不存在"); + } + if (!"in_progress".equals(instance.getStatus())) { + throw new BusinessException("当前状态不允许审批"); + } + + // 获取审批人信息 + SysUser approver = userMapper.selectById(approverId); + + // 创建审批记录 + SysApprovalRecord record = new SysApprovalRecord(); + record.setInstanceId(id); + record.setNodeId(nodeId); + record.setNodeName(instance.getCurrentNodeName()); + record.setApproverId(approverId); + record.setApproverName(approver != null ? approver.getNickname() : "未知用户"); + record.setApproverAvatar(approver != null ? approver.getAvatar() : null); + record.setAction(action); + record.setComment(comment); + record.setOperatedAt(LocalDateTime.now()); + recordMapper.insert(record); + + // 根据操作更新实例状态 + if ("reject".equals(action)) { + instance.setStatus("rejected"); + instance.setCompletedAt(LocalDateTime.now()); + } else if ("approve".equals(action)) { + // 查找下一个节点 + SysApprovalNode currentNode = nodeMapper.selectById(nodeId); + SysApprovalNode nextNode = nodeMapper.selectOne(new LambdaQueryWrapper() + .eq(SysApprovalNode::getTemplateId, instance.getTemplateId()) + .gt(SysApprovalNode::getSort, currentNode != null ? currentNode.getSort() : 0) + .orderByAsc(SysApprovalNode::getSort) + .last("LIMIT 1")); + + if (nextNode != null) { + instance.setCurrentNodeId(nextNode.getId()); + instance.setCurrentNodeName(nextNode.getName()); + } else { + // 没有下一个节点,审批完成 + instance.setStatus("approved"); + instance.setCompletedAt(LocalDateTime.now()); + } + } + + this.updateById(instance); + + log.info("审批操作: id={}, action={}", id, action); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void withdraw(Long id) { + SysApprovalInstance instance = this.getById(id); + if (instance == null) { + throw new BusinessException("审批实例不存在"); + } + if (!"in_progress".equals(instance.getStatus())) { + throw new BusinessException("当前状态不允许撤回"); + } + + instance.setStatus("withdrawn"); + instance.setCompletedAt(LocalDateTime.now()); + this.updateById(instance); + + log.info("撤回审批: id={}", id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancel(Long id) { + SysApprovalInstance instance = this.getById(id); + if (instance == null) { + throw new BusinessException("审批实例不存在"); + } + + instance.setStatus("cancelled"); + instance.setCompletedAt(LocalDateTime.now()); + this.updateById(instance); + + log.info("取消审批: id={}", id); + } + + /** + * 转换为VO + */ + private ApprovalInstanceVO toVO(SysApprovalInstance instance) { + ApprovalInstanceVO vo = new ApprovalInstanceVO(); + BeanUtils.copyProperties(instance, vo); + + // 查询审批记录 + List records = recordMapper.selectList(new LambdaQueryWrapper() + .eq(SysApprovalRecord::getInstanceId, instance.getId()) + .orderByAsc(SysApprovalRecord::getOperatedAt)); + vo.setRecords(records); + + return vo; + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalTemplateServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalTemplateServiceImpl.java new file mode 100644 index 0000000..be15b31 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysApprovalTemplateServiceImpl.java @@ -0,0 +1,228 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.dto.approval.ApprovalStats; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateRequest; +import com.nanxiislet.admin.dto.approval.ApprovalTemplateVO; +import com.nanxiislet.admin.entity.SysApprovalInstance; +import com.nanxiislet.admin.entity.SysApprovalNode; +import com.nanxiislet.admin.entity.SysApprovalTemplate; +import com.nanxiislet.admin.mapper.SysApprovalInstanceMapper; +import com.nanxiislet.admin.mapper.SysApprovalNodeMapper; +import com.nanxiislet.admin.mapper.SysApprovalTemplateMapper; +import com.nanxiislet.admin.service.SysApprovalTemplateService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 审批流程模板服务实现 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Slf4j +@Service +public class SysApprovalTemplateServiceImpl extends ServiceImpl + implements SysApprovalTemplateService { + + @Resource + private SysApprovalNodeMapper nodeMapper; + + @Resource + private SysApprovalInstanceMapper instanceMapper; + + @Override + public ApprovalStats getStats() { + ApprovalStats stats = new ApprovalStats(); + + // 统计模板 + stats.setTotalTemplates(this.count()); + stats.setEnabledTemplates(this.count(new LambdaQueryWrapper() + .eq(SysApprovalTemplate::getEnabled, 1))); + + // 统计实例 + stats.setTotalInstances(instanceMapper.selectCount(null)); + stats.setPendingInstances(instanceMapper.selectCount(new LambdaQueryWrapper() + .eq(SysApprovalInstance::getStatus, "pending"))); + stats.setInProgressInstances(instanceMapper.selectCount(new LambdaQueryWrapper() + .eq(SysApprovalInstance::getStatus, "in_progress"))); + stats.setApprovedInstances(instanceMapper.selectCount(new LambdaQueryWrapper() + .eq(SysApprovalInstance::getStatus, "approved"))); + stats.setRejectedInstances(instanceMapper.selectCount(new LambdaQueryWrapper() + .eq(SysApprovalInstance::getStatus, "rejected"))); + + return stats; + } + + @Override + public IPage listPage(Integer page, Integer pageSize, String scenario, Boolean enabled) { + Page pageParam = new Page<>(page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(scenario)) { + wrapper.eq(SysApprovalTemplate::getScenario, scenario); + } + if (enabled != null) { + wrapper.eq(SysApprovalTemplate::getEnabled, enabled ? 1 : 0); + } + wrapper.orderByDesc(SysApprovalTemplate::getUpdatedAt); + + IPage result = this.page(pageParam, wrapper); + + // 转换为VO并填充节点 + Page voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); + voPage.setRecords(result.getRecords().stream() + .map(this::toVO) + .collect(Collectors.toList())); + + return voPage; + } + + @Override + public List listAll(String scenario) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(scenario)) { + wrapper.eq(SysApprovalTemplate::getScenario, scenario); + } + wrapper.orderByDesc(SysApprovalTemplate::getUpdatedAt); + + return this.list(wrapper).stream() + .map(this::toVO) + .collect(Collectors.toList()); + } + + @Override + public ApprovalTemplateVO getDetail(Long id) { + SysApprovalTemplate template = this.getById(id); + if (template == null) { + throw new BusinessException("模板不存在"); + } + return toVO(template); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(ApprovalTemplateRequest request) { + // 创建模板 + SysApprovalTemplate template = new SysApprovalTemplate(); + template.setName(request.getName()); + template.setDescription(request.getDescription()); + template.setScenario(request.getScenario()); + template.setEnabled(request.getEnabled() != null && request.getEnabled() ? 1 : 0); + this.save(template); + + // 创建节点 + if (request.getNodes() != null && !request.getNodes().isEmpty()) { + int sort = 1; + for (SysApprovalNode node : request.getNodes()) { + node.setId(null); + node.setTemplateId(template.getId()); + node.setSort(sort++); + nodeMapper.insert(node); + } + } + + log.info("创建审批模板: id={}, name={}", template.getId(), template.getName()); + return template.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(ApprovalTemplateRequest request) { + if (request.getId() == null) { + throw new BusinessException("模板ID不能为空"); + } + + SysApprovalTemplate template = this.getById(request.getId()); + if (template == null) { + throw new BusinessException("模板不存在"); + } + + // 更新模板 + template.setName(request.getName()); + template.setDescription(request.getDescription()); + template.setScenario(request.getScenario()); + if (request.getEnabled() != null) { + template.setEnabled(request.getEnabled() ? 1 : 0); + } + this.updateById(template); + + // 删除旧节点 + nodeMapper.delete(new LambdaQueryWrapper() + .eq(SysApprovalNode::getTemplateId, template.getId())); + + // 创建新节点 + if (request.getNodes() != null && !request.getNodes().isEmpty()) { + int sort = 1; + for (SysApprovalNode node : request.getNodes()) { + node.setId(null); + node.setTemplateId(template.getId()); + node.setSort(sort++); + nodeMapper.insert(node); + } + } + + log.info("更新审批模板: id={}, name={}", template.getId(), template.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + // 检查是否有使用中的实例 + long instanceCount = instanceMapper.selectCount(new LambdaQueryWrapper() + .eq(SysApprovalInstance::getTemplateId, id) + .in(SysApprovalInstance::getStatus, "pending", "in_progress")); + if (instanceCount > 0) { + throw new BusinessException("存在未完成的审批实例,无法删除"); + } + + // 删除节点 + nodeMapper.delete(new LambdaQueryWrapper() + .eq(SysApprovalNode::getTemplateId, id)); + + // 删除模板 + this.removeById(id); + + log.info("删除审批模板: id={}", id); + } + + @Override + public void toggle(Long id) { + SysApprovalTemplate template = this.getById(id); + if (template == null) { + throw new BusinessException("模板不存在"); + } + + template.setEnabled(template.getEnabled() == 1 ? 0 : 1); + this.updateById(template); + + log.info("切换审批模板状态: id={}, enabled={}", id, template.getEnabled()); + } + + /** + * 转换为VO + */ + private ApprovalTemplateVO toVO(SysApprovalTemplate template) { + ApprovalTemplateVO vo = new ApprovalTemplateVO(); + BeanUtils.copyProperties(template, vo); + + // 查询节点 + List nodes = nodeMapper.selectList(new LambdaQueryWrapper() + .eq(SysApprovalNode::getTemplateId, template.getId()) + .orderByAsc(SysApprovalNode::getSort)); + vo.setNodes(nodes); + + return vo; + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysDeptServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysDeptServiceImpl.java new file mode 100644 index 0000000..f7aaf3f --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysDeptServiceImpl.java @@ -0,0 +1,139 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.entity.SysDept; +import com.nanxiislet.admin.mapper.SysDeptMapper; +import com.nanxiislet.admin.service.SysDeptService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 部门服务实现 + * + * @author NanxiIslet + * @since 2026-01-09 + */ +@Slf4j +@Service +public class SysDeptServiceImpl extends ServiceImpl implements SysDeptService { + + @Override + public List listTree() { + // 获取所有部门 + List allDepts = this.list(new LambdaQueryWrapper() + .eq(SysDept::getStatus, 1) + .orderByAsc(SysDept::getSort) + .orderByAsc(SysDept::getId)); + + // 构建树形结构 + return buildTree(allDepts); + } + + @Override + public List listAll() { + return this.list(new LambdaQueryWrapper() + .eq(SysDept::getStatus, 1) + .orderByAsc(SysDept::getSort) + .orderByAsc(SysDept::getId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(SysDept dept) { + // 检查父部门是否存在 + if (dept.getParentId() != null && dept.getParentId() > 0) { + SysDept parent = this.getById(dept.getParentId()); + if (parent == null) { + throw new BusinessException("父部门不存在"); + } + } else { + dept.setParentId(0L); + } + + // 检查部门编码是否重复 + if (dept.getCode() != null) { + long count = this.count(new LambdaQueryWrapper() + .eq(SysDept::getCode, dept.getCode())); + if (count > 0) { + throw new BusinessException("部门编码已存在"); + } + } + + this.save(dept); + log.info("创建部门: id={}, name={}", dept.getId(), dept.getName()); + return dept.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDept(SysDept dept) { + SysDept existing = this.getById(dept.getId()); + if (existing == null) { + throw new BusinessException("部门不存在"); + } + + // 检查是否将自己设为父部门 + if (dept.getParentId() != null && dept.getParentId().equals(dept.getId())) { + throw new BusinessException("不能将自己设为父部门"); + } + + // 检查部门编码是否重复 + if (dept.getCode() != null) { + long count = this.count(new LambdaQueryWrapper() + .eq(SysDept::getCode, dept.getCode()) + .ne(SysDept::getId, dept.getId())); + if (count > 0) { + throw new BusinessException("部门编码已存在"); + } + } + + this.updateById(dept); + log.info("更新部门: id={}, name={}", dept.getId(), dept.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDept(Long id) { + // 检查是否有子部门 + long childCount = this.count(new LambdaQueryWrapper() + .eq(SysDept::getParentId, id)); + if (childCount > 0) { + throw new BusinessException("存在子部门,无法删除"); + } + + this.removeById(id); + log.info("删除部门: id={}", id); + } + + @Override + public List getDeptUserIds(Long deptId) { + // TODO: 实现获取部门用户ID列表 + // 这里需要关联用户表查询 + return new ArrayList<>(); + } + + /** + * 构建树形结构 + */ + private List buildTree(List list) { + // 按父ID分组 + Map> parentMap = list.stream() + .collect(Collectors.groupingBy(SysDept::getParentId)); + + // 为每个节点设置子节点 + list.forEach(dept -> dept.setChildren(parentMap.get(dept.getId()))); + + // 返回顶级节点 + return list.stream() + .filter(dept -> dept.getParentId() == null || dept.getParentId() == 0) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysDictItemServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysDictItemServiceImpl.java new file mode 100644 index 0000000..dbfa754 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysDictItemServiceImpl.java @@ -0,0 +1,101 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.entity.SysDict; +import com.nanxiislet.admin.entity.SysDictItem; +import com.nanxiislet.admin.mapper.SysDictItemMapper; +import com.nanxiislet.admin.mapper.SysDictMapper; +import com.nanxiislet.admin.service.SysDictItemService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 字典项 Service 实现 + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Slf4j +@Service +public class SysDictItemServiceImpl extends ServiceImpl implements SysDictItemService { + + @Resource + private SysDictMapper dictMapper; + + @Override + public List listByDictId(Long dictId) { + // 管理后台查询所有字典项(包括禁用的) + return list(new LambdaQueryWrapper() + .eq(SysDictItem::getDictId, dictId) + .orderByAsc(SysDictItem::getSort)); + } + + @Override + public List listByDictCode(String dictCode) { + // 直接使用 Mapper 查询,避免循环依赖 + SysDict dict = dictMapper.selectOne(new LambdaQueryWrapper() + .eq(SysDict::getCode, dictCode)); + if (dict == null) { + throw new BusinessException("字典不存在: " + dictCode); + } + return listByDictId(dict.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(SysDictItem item) { + // 检查字典是否存在 + SysDict dict = dictMapper.selectById(item.getDictId()); + if (dict == null) { + throw new BusinessException("字典不存在"); + } + // 检查值是否存在 + SysDictItem existing = getOne(new LambdaQueryWrapper() + .eq(SysDictItem::getDictId, item.getDictId()) + .eq(SysDictItem::getValue, item.getValue())); + if (existing != null) { + throw new BusinessException("字典项值已存在"); + } + item.setStatus(1); + if (item.getSort() == null) { + item.setSort(0); + } + if (item.getIsDefault() == null) { + item.setIsDefault(0); + } + save(item); + return item.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateItem(SysDictItem item) { + // 检查值是否存在 + SysDictItem existing = getOne(new LambdaQueryWrapper() + .eq(SysDictItem::getDictId, item.getDictId()) + .eq(SysDictItem::getValue, item.getValue())); + if (existing != null && !existing.getId().equals(item.getId())) { + throw new BusinessException("字典项值已存在"); + } + updateById(item); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteItem(Long id) { + removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteByDictId(Long dictId) { + remove(new LambdaQueryWrapper() + .eq(SysDictItem::getDictId, dictId)); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysDictServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysDictServiceImpl.java new file mode 100644 index 0000000..317741d --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysDictServiceImpl.java @@ -0,0 +1,82 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.entity.SysDict; +import com.nanxiislet.admin.mapper.SysDictMapper; +import com.nanxiislet.admin.service.SysDictItemService; +import com.nanxiislet.admin.service.SysDictService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 字典类型 Service 实现 + * + * @author NanxiIslet + * @since 2026-01-08 + */ +@Slf4j +@Service +public class SysDictServiceImpl extends ServiceImpl implements SysDictService { + + @Resource + private SysDictItemService dictItemService; + + @Override + public IPage page(Integer page, Integer pageSize, String name, String code) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(name)) { + wrapper.like(SysDict::getName, name); + } + if (StringUtils.hasText(code)) { + wrapper.like(SysDict::getCode, code); + } + wrapper.orderByDesc(SysDict::getCreatedAt); + return page(new Page<>(page, pageSize), wrapper); + } + + @Override + public SysDict getByCode(String code) { + return getOne(new LambdaQueryWrapper() + .eq(SysDict::getCode, code)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(SysDict dict) { + // 检查编码是否存在 + SysDict existing = getByCode(dict.getCode()); + if (existing != null) { + throw new BusinessException("字典编码已存在"); + } + dict.setStatus(1); + save(dict); + return dict.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDict(SysDict dict) { + // 检查编码是否存在 + SysDict existing = getByCode(dict.getCode()); + if (existing != null && !existing.getId().equals(dict.getId())) { + throw new BusinessException("字典编码已存在"); + } + updateById(dict); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDict(Long id) { + // 删除字典项 + dictItemService.deleteByDictId(id); + // 删除字典 + removeById(id); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysMenuServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysMenuServiceImpl.java new file mode 100644 index 0000000..eb763a7 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysMenuServiceImpl.java @@ -0,0 +1,102 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.entity.SysMenu; +import com.nanxiislet.admin.entity.SysRoleMenu; +import com.nanxiislet.admin.mapper.SysMenuMapper; +import com.nanxiislet.admin.mapper.SysRoleMenuMapper; +import com.nanxiislet.admin.service.SysMenuService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 菜单服务实现 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Slf4j +@Service +public class SysMenuServiceImpl extends ServiceImpl implements SysMenuService { + + @Resource + private SysRoleMenuMapper roleMenuMapper; + + @Override + public List listTree() { + // 获取所有菜单并按sort排序 + return list(new LambdaQueryWrapper() + .eq(SysMenu::getStatus, 1) + .orderByAsc(SysMenu::getSort)); + } + + @Override + public List listAll() { + return list(new LambdaQueryWrapper() + .orderByAsc(SysMenu::getParentId) + .orderByAsc(SysMenu::getSort)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(SysMenu menu) { + // 检查编码是否存在 + SysMenu existing = getOne(new LambdaQueryWrapper() + .eq(SysMenu::getCode, menu.getCode())); + if (existing != null) { + throw new BusinessException("菜单编码已存在"); + } + menu.setStatus(1); + if (menu.getParentId() == null) { + menu.setParentId(0L); + } + save(menu); + return menu.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMenu(SysMenu menu) { + // 检查编码是否存在 + SysMenu existing = getOne(new LambdaQueryWrapper() + .eq(SysMenu::getCode, menu.getCode())); + if (existing != null && !existing.getId().equals(menu.getId())) { + throw new BusinessException("菜单编码已存在"); + } + updateById(menu); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteMenu(Long id) { + // 检查是否有子菜单 + long childCount = count(new LambdaQueryWrapper() + .eq(SysMenu::getParentId, id)); + if (childCount > 0) { + throw new BusinessException("该菜单下有子菜单,无法删除"); + } + + // 删除角色菜单关联 + roleMenuMapper.delete(new LambdaQueryWrapper() + .eq(SysRoleMenu::getMenuId, id)); + + // 删除菜单 + removeById(id); + } + + @Override + public List getMenusByRoleCode(String roleCode) { + return baseMapper.selectMenusByRoleCode(roleCode); + } + + @Override + public List getMenusByRoleId(Long roleId) { + return baseMapper.selectMenusByRoleId(roleId); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/SysRoleServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..6744700 --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/SysRoleServiceImpl.java @@ -0,0 +1,122 @@ +package com.nanxiislet.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.entity.SysRole; +import com.nanxiislet.admin.entity.SysRoleMenu; +import com.nanxiislet.admin.mapper.SysRoleMapper; +import com.nanxiislet.admin.mapper.SysRoleMenuMapper; +import com.nanxiislet.admin.service.SysRoleService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 角色服务实现 + * + * @author NanxiIslet + * @since 2024-01-08 + */ +@Slf4j +@Service +public class SysRoleServiceImpl extends ServiceImpl implements SysRoleService { + + @Resource + private SysRoleMenuMapper roleMenuMapper; + + @Override + public IPage listPage(Integer page, Integer pageSize, String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.isNotBlank(keyword)) { + wrapper.like(SysRole::getName, keyword) + .or().like(SysRole::getCode, keyword); + } + wrapper.orderByAsc(SysRole::getSort); + return page(new Page<>(page, pageSize), wrapper); + } + + @Override + public List listAll() { + return list(new LambdaQueryWrapper() + .eq(SysRole::getStatus, 1) + .orderByAsc(SysRole::getSort)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(SysRole role) { + // 检查编码是否存在 + if (getByCode(role.getCode()) != null) { + throw new BusinessException("角色编码已存在"); + } + role.setStatus(1); + save(role); + return role.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRole(SysRole role) { + // 检查编码是否存在 + SysRole existing = getByCode(role.getCode()); + if (existing != null && !existing.getId().equals(role.getId())) { + throw new BusinessException("角色编码已存在"); + } + updateById(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRole(Long id) { + // 删除角色菜单关联 + roleMenuMapper.delete(new LambdaQueryWrapper() + .eq(SysRoleMenu::getRoleId, id)); + // 删除角色 + removeById(id); + } + + @Override + public SysRole getByCode(String code) { + return getOne(new LambdaQueryWrapper() + .eq(SysRole::getCode, code)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignMenus(Long roleId, List menuIds) { + // 先删除原有关联 + roleMenuMapper.delete(new LambdaQueryWrapper() + .eq(SysRoleMenu::getRoleId, roleId)); + + // 添加新关联 + if (menuIds != null && !menuIds.isEmpty()) { + List roleMenus = menuIds.stream() + .map(menuId -> { + SysRoleMenu rm = new SysRoleMenu(); + rm.setRoleId(roleId); + rm.setMenuId(menuId); + return rm; + }) + .collect(Collectors.toList()); + roleMenus.forEach(roleMenuMapper::insert); + } + } + + @Override + public List getRoleMenuIds(Long roleId) { + List roleMenus = roleMenuMapper.selectList( + new LambdaQueryWrapper() + .eq(SysRoleMenu::getRoleId, roleId)); + return roleMenus.stream() + .map(SysRoleMenu::getMenuId) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/nanxiislet/admin/service/impl/UserServiceImpl.java b/src/main/java/com/nanxiislet/admin/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..b49462b --- /dev/null +++ b/src/main/java/com/nanxiislet/admin/service/impl/UserServiceImpl.java @@ -0,0 +1,145 @@ +package com.nanxiislet.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.nanxiislet.admin.common.exception.BusinessException; +import com.nanxiislet.admin.common.result.ResultCode; +import com.nanxiislet.admin.dto.system.UserQuery; +import com.nanxiislet.admin.entity.SysUser; +import com.nanxiislet.admin.mapper.SysUserMapper; +import com.nanxiislet.admin.service.UserService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +/** + * 用户管理服务实现 + * + * @author NanxiIslet + * @since 2024-01-06 + */ +@Service +public class UserServiceImpl extends ServiceImpl implements UserService { + + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Override + public Page listPage(UserQuery query) { + Page page = new Page<>(query.getPage(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 关键词搜索 + if (StrUtil.isNotBlank(query.getKeyword())) { + wrapper.and(w -> w + .like(SysUser::getUsername, query.getKeyword()) + .or().like(SysUser::getNickname, query.getKeyword()) + .or().like(SysUser::getPhone, query.getKeyword()) + .or().like(SysUser::getEmail, query.getKeyword()) + ); + } + + // 部门筛选 + if (query.getDeptId() != null) { + wrapper.eq(SysUser::getDeptId, query.getDeptId()); + } + + // 角色筛选 + if (StrUtil.isNotBlank(query.getRole())) { + wrapper.eq(SysUser::getRole, query.getRole()); + } + + // 状态筛选 + if (query.getStatus() != null) { + wrapper.eq(SysUser::getStatus, query.getStatus()); + } + + // 默认按创建时间倒序 + wrapper.orderByDesc(SysUser::getCreatedAt); + + Page result = page(page, wrapper); + + // 清除密码字段 + result.getRecords().forEach(user -> user.setPassword(null)); + + return result; + } + + @Override + public void createUser(SysUser user) { + // 检查用户名是否存在 + if (isUsernameExists(user.getUsername(), null)) { + throw new BusinessException(ResultCode.USER_EXISTS); + } + + // 加密密码 + if (StrUtil.isNotBlank(user.getPassword())) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + } else { + // 默认密码 + user.setPassword(passwordEncoder.encode("123456")); + } + + // 默认状态 + if (user.getStatus() == null) { + user.setStatus(1); + } + + save(user); + } + + @Override + public void updateUser(SysUser user) { + // 检查用户名是否存在 + if (isUsernameExists(user.getUsername(), user.getId())) { + throw new BusinessException(ResultCode.USER_EXISTS); + } + + // 不更新密码 + user.setPassword(null); + + updateById(user); + } + + @Override + public void resetPassword(Long userId, String newPassword) { + SysUser user = getById(userId); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + SysUser updateUser = new SysUser(); + updateUser.setId(userId); + updateUser.setPassword(passwordEncoder.encode(newPassword)); + updateById(updateUser); + } + + @Override + public void changePassword(Long userId, String oldPassword, String newPassword) { + SysUser user = getById(userId); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + // 验证旧密码 + if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + throw new BusinessException("原密码错误"); + } + + SysUser updateUser = new SysUser(); + updateUser.setId(userId); + updateUser.setPassword(passwordEncoder.encode(newPassword)); + updateById(updateUser); + } + + @Override + public boolean isUsernameExists(String username, Long excludeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysUser::getUsername, username); + if (excludeId != null) { + wrapper.ne(SysUser::getId, excludeId); + } + return count(wrapper) > 0; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..5ad4d37 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,174 @@ +# Spring Boot 配置 +server: + port: 8080 + servlet: + context-path: /api + +spring: + application: + name: nanxiislet-admin + profiles: + active: dev + + # 数据库配置 + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://47.109.57.58:3306/nanxiislet?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: nanxiislet + password: YNTfc4GHRF7A267f + druid: + initial-size: 5 + min-idle: 5 + max-active: 20 + max-wait: 60000 + # 缩短空闲检测间隔,从60秒改为30秒 + time-between-eviction-runs-millis: 30000 + # 缩短最小空闲时间,防止连接在MySQL端超时后仍被池持有 + min-evictable-idle-time-millis: 300000 + max-evictable-idle-time-millis: 1800000 + validation-query: SELECT 1 + test-while-idle: true + # 开启借用连接时的检测,确保获取的连接是有效的(虽然稍微损耗性能,但能彻底解决EOF问题) + test-on-borrow: true + test-on-return: false + # 保持连接活跃 + keep-alive: true + filter: + stat: + enabled: true + slow-sql-millis: 1000 + log-slow-sql: true + wall: + enabled: true + slf4j: + enabled: true + + # Redis配置 + data: + redis: + host: 47.109.57.58 + port: 6379 + password: redis_YAB3QN + database: 0 + timeout: 10s + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + shutdown-timeout: 100ms + cluster: + refresh: + adaptive: true + period: 60s + + # SQL初始化配置 - 首次运行后请改为 never + sql: + init: + mode: never # 已初始化完成,改为never避免每次启动重置数据 + schema-locations: classpath:schema.sql + continue-on-error: true + + # Jackson配置 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + serialization: + write-dates-as-timestamps: false + default-property-inclusion: non_null + + # 文件上传配置 - 支持大文件上传 + servlet: + multipart: + enabled: true + max-file-size: 500MB + max-request-size: 500MB + file-size-threshold: 10MB # 超过 10MB 时写入临时文件 + + # 异步请求超时 - 30分钟 + mvc: + async: + request-timeout: 1800000 + +# MyBatis-Plus 配置 +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + type-aliases-package: com.nanxiislet.admin.entity + global-config: + db-config: + id-type: auto + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + banner: false + configuration: + map-underscore-to-camel-case: true + cache-enabled: false + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +# Sa-Token 配置 +sa-token: + token-name: Authorization + token-prefix: Bearer + timeout: 86400 + active-timeout: 3600 + is-concurrent: true + is-share: false + token-style: uuid + is-log: true + +# Knife4j 配置 +springdoc: + api-docs: + path: /v3/api-docs + enabled: true + swagger-ui: + path: /swagger-ui.html + enabled: true +knife4j: + enable: true + setting: + language: zh_cn + +# 自定义配置 +nanxiislet: + # 数据库初始化配置 + db: + init: true # 首次启动设为true,初始化完成后改为false + + # JWT配置 + jwt: + secret: NanxiIsletAdminSecretKey20240106ABCDEFGHIJ1234567890 + expiration: 86400000 + refresh-expiration: 604800000 + + # 验证码配置 + captcha: + enabled: true + expire: 300 + + # 文件上传配置 + upload: + path: ./uploads + allowed-types: + - image/jpeg + - image/png + - image/gif + - application/pdf + - application/zip + - application/x-zip-compressed + max-size: 104857600 + +# 日志配置 +logging: + level: + root: info + com.nanxiislet: debug + com.baomidou.mybatisplus: debug + file: + name: logs/nanxiislet-admin.log + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" diff --git a/src/main/resources/db/V2__user_role_menu.sql b/src/main/resources/db/V2__user_role_menu.sql new file mode 100644 index 0000000..b05f9be --- /dev/null +++ b/src/main/resources/db/V2__user_role_menu.sql @@ -0,0 +1,132 @@ +-- ================================== +-- 用户角色管理模块 - 数据库增量脚本 +-- 创建日期: 2026-01-08 +-- ================================== + +USE `nanxiislet`; + +-- ================================== +-- 角色表 +-- ================================== +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` VARCHAR(50) NOT NULL COMMENT '角色编码', + `name` VARCHAR(100) NOT NULL COMMENT '角色名称', + `description` VARCHAR(500) DEFAULT NULL COMMENT '角色描述', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; + +-- ================================== +-- 菜单表 +-- ================================== +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID', + `name` VARCHAR(100) NOT NULL COMMENT '菜单名称', + `code` VARCHAR(100) NOT NULL COMMENT '菜单编码/Key', + `type` VARCHAR(20) DEFAULT 'menu' COMMENT '菜单类型 directory-目录 menu-菜单 button-按钮', + `path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径', + `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径', + `icon` VARCHAR(100) DEFAULT NULL COMMENT '图标', + `permission` VARCHAR(100) DEFAULT NULL COMMENT '权限标识', + `sort` INT DEFAULT 0 COMMENT '排序', + `hidden` TINYINT DEFAULT 0 COMMENT '是否隐藏 0-显示 1-隐藏', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单表'; + +-- ================================== +-- 角色菜单关联表 +-- ================================== +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `menu_id` BIGINT NOT NULL COMMENT '菜单ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`), + KEY `idx_role_id` (`role_id`), + KEY `idx_menu_id` (`menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单关联表'; + +-- ================================== +-- 初始化角色数据 +-- ================================== +INSERT INTO `sys_role` (`code`, `name`, `description`, `sort`) VALUES +('super_admin', '超级管理员', '拥有系统所有权限', 1), +('admin', '管理员', '系统管理员,可管理大部分功能', 2), +('finance', '财务', '财务人员,只能访问财务相关功能', 3), +('customer_service', '客服', '客服人员,只能访问客服相关功能', 4); + +-- ================================== +-- 初始化菜单数据 +-- ================================== +-- 财务管理 +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(0, '财务管理', 'finance', 'directory', '/finance', 'PayCircleOutlined', 1); + +SET @finance_id = LAST_INSERT_ID(); + +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(@finance_id, '财务总览', 'finance-overview', 'menu', '/finance/overview', 'DashboardOutlined', 1), +(@finance_id, '收入管理', 'income', 'menu', '/finance/income', 'RiseOutlined', 2), +(@finance_id, '支出管理', 'expense', 'menu', '/finance/expense', 'FallOutlined', 3), +(@finance_id, '报销管理', 'reimbursement', 'menu', '/finance/reimbursement', 'AuditOutlined', 4), +(@finance_id, '结算管理', 'settlement', 'menu', '/finance/settlement', 'TransactionOutlined', 5), +(@finance_id, '发票管理', 'invoice', 'menu', '/finance/invoice', 'FileTextOutlined', 6), +(@finance_id, '账户管理', 'finance-accounts', 'menu', '/finance/accounts', 'BankOutlined', 7), +(@finance_id, '预算管理', 'budget', 'menu', '/finance/budget', 'FundOutlined', 8), +(@finance_id, '财务报表', 'finance-reports', 'menu', '/finance/reports', 'BarChartOutlined', 9); + +-- 平台管理 +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(0, '平台管理', 'platform', 'directory', '/platform', 'AppstoreOutlined', 2); + +SET @platform_id = LAST_INSERT_ID(); + +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(@platform_id, '项目管理', 'platform-projects', 'menu', '/platform/projects', 'ProjectOutlined', 1), +(@platform_id, '服务器管理', 'platform-servers', 'menu', '/platform/servers', 'CloudServerOutlined', 2), +(@platform_id, '域名管理', 'platform-domains', 'menu', '/platform/domains', 'GlobalOutlined', 3), +(@platform_id, '证书管理', 'platform-certificates', 'menu', '/platform/certificates', 'SafetyCertificateOutlined', 4); + +-- 系统管理 +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(0, '系统管理', 'system', 'directory', '/system', 'SettingOutlined', 3); + +SET @system_id = LAST_INSERT_ID(); + +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(@system_id, '用户管理', 'system-users', 'menu', '/system/users', 'UserOutlined', 1), +(@system_id, '角色管理', 'system-roles', 'menu', '/system/roles', 'TeamOutlined', 2), +(@system_id, '菜单管理', 'system-menus', 'menu', '/system/menus', 'MenuOutlined', 3), +(@system_id, '字典管理', 'system-dict', 'menu', '/system/dict', 'BookOutlined', 4), +(@system_id, '审批流程', 'system-approval', 'menu', '/system/approval', 'ApartmentOutlined', 5), +(@system_id, '审批实例', 'system-approval-instances', 'menu', '/system/approval/instances', 'AuditOutlined', 6); + +-- ================================== +-- 为超级管理员分配所有菜单 +-- ================================== +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT 1, id FROM `sys_menu`; + +-- 更新现有用户的角色为admin +UPDATE `sys_user` SET `role` = 'super_admin' WHERE `username` = 'admin'; diff --git a/src/main/resources/db/V3__dict.sql b/src/main/resources/db/V3__dict.sql new file mode 100644 index 0000000..760610a --- /dev/null +++ b/src/main/resources/db/V3__dict.sql @@ -0,0 +1,76 @@ +-- ================================== +-- 字典管理模块 - 数据库增量脚本 +-- 创建日期: 2026-01-08 +-- ================================== + +USE `nanxiislet`; + +-- ================================== +-- 字典类型表 +-- ================================== +DROP TABLE IF EXISTS `sys_dict`; +CREATE TABLE `sys_dict` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '字典名称', + `code` VARCHAR(100) NOT NULL COMMENT '字典编码', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='字典类型表'; + +-- ================================== +-- 字典项表 +-- ================================== +DROP TABLE IF EXISTS `sys_dict_item`; +CREATE TABLE `sys_dict_item` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `dict_id` BIGINT NOT NULL COMMENT '字典ID', + `label` VARCHAR(100) NOT NULL COMMENT '字典项标签', + `value` VARCHAR(100) NOT NULL COMMENT '字典项值', + `sort` INT DEFAULT 0 COMMENT '排序', + `is_default` TINYINT DEFAULT 0 COMMENT '是否默认 0-否 1-是', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_dict_id` (`dict_id`), + UNIQUE KEY `uk_dict_value` (`dict_id`, `value`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='字典项表'; + +-- ================================== +-- 初始化字典数据 +-- ================================== +INSERT INTO `sys_dict` (`name`, `code`, `remark`) VALUES +('性别', 'gender', '用户性别'), +('状态', 'status', '通用状态'), +('审批状态', 'approval_status', '审批流程状态'); + +-- 性别字典项 +SET @gender_id = (SELECT id FROM sys_dict WHERE code = 'gender'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `is_default`) VALUES +(@gender_id, '男', '1', 1, 1), +(@gender_id, '女', '2', 2, 0), +(@gender_id, '未知', '0', 3, 0); + +-- 状态字典项 +SET @status_id = (SELECT id FROM sys_dict WHERE code = 'status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `is_default`) VALUES +(@status_id, '正常', '1', 1, 1), +(@status_id, '禁用', '0', 2, 0); + +-- 审批状态字典项 +SET @approval_id = (SELECT id FROM sys_dict WHERE code = 'approval_status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `is_default`) VALUES +(@approval_id, '待审批', 'pending', 1, 1), +(@approval_id, '已通过', 'approved', 2, 0), +(@approval_id, '已驳回', 'rejected', 3, 0); diff --git a/src/main/resources/db/V4__approval.sql b/src/main/resources/db/V4__approval.sql new file mode 100644 index 0000000..0235348 --- /dev/null +++ b/src/main/resources/db/V4__approval.sql @@ -0,0 +1,163 @@ +-- ================================== +-- 审批流程管理表 +-- 创建日期: 2026-01-09 +-- ================================== + +-- ================================== +-- 部门管理表 +-- ================================== +DROP TABLE IF EXISTS `sys_dept`; +CREATE TABLE `sys_dept` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父部门ID,0表示顶级部门', + `name` VARCHAR(100) NOT NULL COMMENT '部门名称', + `code` VARCHAR(50) DEFAULT NULL COMMENT '部门编码', + `leader_id` BIGINT DEFAULT NULL COMMENT '部门负责人ID', + `leader_name` VARCHAR(50) DEFAULT NULL COMMENT '部门负责人名称', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_code` (`code`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门管理表'; + +-- 初始化部门数据 +INSERT INTO `sys_dept` (`parent_id`, `name`, `code`, `sort`, `status`) VALUES +(0, '南溪屿科技', 'ROOT', 0, 1), +(1, '技术部', 'TECH', 1, 1), +(1, '产品部', 'PRODUCT', 2, 1), +(1, '财务部', 'FINANCE', 3, 1), +(1, '人力资源部', 'HR', 4, 1), +(2, '前端开发组', 'TECH-FE', 1, 1), +(2, '后端开发组', 'TECH-BE', 2, 1), +(2, '运维组', 'TECH-OPS', 3, 1); + +-- ================================== +-- 审批流程模板表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_template`; +CREATE TABLE `sys_approval_template` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '模板名称', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景: project_publish/withdrawal/contract/certification/content/expense_reimbursement/payment_request/purchase_request/budget_adjustment/invoice_apply', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用 0-禁用 1-启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_scenario` (`scenario`), + KEY `idx_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程模板表'; + +-- ================================== +-- 审批流程节点表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_node`; +CREATE TABLE `sys_approval_node` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `name` VARCHAR(100) NOT NULL COMMENT '节点名称', + `approver_type` VARCHAR(20) NOT NULL DEFAULT 'specified' COMMENT '审批人类型: specified-指定人员 role-按角色 department-按部门 superior-上级领导 self_select-发起人自选', + `approver_ids` JSON DEFAULT NULL COMMENT '审批人ID列表(JSON数组)', + `approver_role` VARCHAR(50) DEFAULT NULL COMMENT '审批角色编码', + `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `dept_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `approval_mode` VARCHAR(10) DEFAULT 'or' COMMENT '审批方式: and-会签 or-或签', + `timeout_hours` INT DEFAULT NULL COMMENT '超时时间(小时)', + `timeout_action` VARCHAR(20) DEFAULT NULL COMMENT '超时操作: skip-跳过 reject-驳回', + `sort` INT DEFAULT 0 COMMENT '节点顺序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`), + KEY `idx_sort` (`sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程节点表'; + +-- ================================== +-- 审批实例表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_instance`; +CREATE TABLE `sys_approval_instance` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `template_name` VARCHAR(100) DEFAULT NULL COMMENT '模板名称', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景', + `business_type` VARCHAR(50) NOT NULL COMMENT '业务类型', + `business_id` BIGINT NOT NULL COMMENT '业务ID', + `business_title` VARCHAR(200) DEFAULT NULL COMMENT '业务标题', + `initiator_id` BIGINT NOT NULL COMMENT '发起人ID', + `initiator_name` VARCHAR(50) DEFAULT NULL COMMENT '发起人名称', + `initiator_avatar` VARCHAR(255) DEFAULT NULL COMMENT '发起人头像', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending-待提交 in_progress-审批中 approved-已通过 rejected-已拒绝 withdrawn-已撤回 cancelled-已取消', + `current_node_id` BIGINT DEFAULT NULL COMMENT '当前节点ID', + `current_node_name` VARCHAR(100) DEFAULT NULL COMMENT '当前节点名称', + `submitted_at` DATETIME DEFAULT NULL COMMENT '提交时间', + `completed_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`), + KEY `idx_business` (`business_type`, `business_id`), + KEY `idx_initiator_id` (`initiator_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批实例表'; + +-- ================================== +-- 审批记录表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_record`; +CREATE TABLE `sys_approval_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `instance_id` BIGINT NOT NULL COMMENT '实例ID', + `node_id` BIGINT NOT NULL COMMENT '节点ID', + `node_name` VARCHAR(100) DEFAULT NULL COMMENT '节点名称', + `approver_id` BIGINT NOT NULL COMMENT '审批人ID', + `approver_name` VARCHAR(50) DEFAULT NULL COMMENT '审批人名称', + `approver_avatar` VARCHAR(255) DEFAULT NULL COMMENT '审批人头像', + `action` VARCHAR(20) NOT NULL COMMENT '操作: approve-通过 reject-驳回 transfer-转交 return-退回', + `comment` VARCHAR(500) DEFAULT NULL COMMENT '审批意见', + `operated_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_instance_id` (`instance_id`), + KEY `idx_approver_id` (`approver_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批记录表'; + +-- ================================== +-- 初始化审批流程模板示例数据 +-- ================================== +INSERT INTO `sys_approval_template` (`name`, `description`, `scenario`, `enabled`) VALUES +('费用报销审批', '员工费用报销审批流程', 'expense_reimbursement', 1), +('付款申请审批', '付款申请审批流程', 'payment_request', 1), +('项目发布审批', '项目发布前的审批流程', 'project_publish', 1); + +-- 为费用报销添加审批节点 +SET @expense_template_id = (SELECT id FROM sys_approval_template WHERE scenario = 'expense_reimbursement' LIMIT 1); +INSERT INTO `sys_approval_node` (`template_id`, `name`, `approver_type`, `approver_role`, `approval_mode`, `sort`) VALUES +(@expense_template_id, '部门经理审批', 'role', 'manager', 'or', 1), +(@expense_template_id, '财务审核', 'role', 'finance', 'or', 2), +(@expense_template_id, '总经理审批', 'role', 'admin', 'or', 3); + +-- 为付款申请添加审批节点 +SET @payment_template_id = (SELECT id FROM sys_approval_template WHERE scenario = 'payment_request' LIMIT 1); +INSERT INTO `sys_approval_node` (`template_id`, `name`, `approver_type`, `approver_role`, `approval_mode`, `sort`) VALUES +(@payment_template_id, '财务审核', 'role', 'finance', 'or', 1), +(@payment_template_id, '总经理审批', 'role', 'admin', 'or', 2); + +-- 为项目发布添加审批节点 +SET @project_template_id = (SELECT id FROM sys_approval_template WHERE scenario = 'project_publish' LIMIT 1); +INSERT INTO `sys_approval_node` (`template_id`, `name`, `approver_type`, `approver_role`, `approval_mode`, `sort`) VALUES +(@project_template_id, '项目经理审核', 'role', 'manager', 'or', 1), +(@project_template_id, '技术总监审批', 'role', 'admin', 'or', 2); diff --git a/src/main/resources/db/V5__dept_menu.sql b/src/main/resources/db/V5__dept_menu.sql new file mode 100644 index 0000000..0a7f0e1 --- /dev/null +++ b/src/main/resources/db/V5__dept_menu.sql @@ -0,0 +1,23 @@ +-- ================================== +-- 添加部门管理菜单 +-- 创建日期: 2026-01-09 +-- ================================== + +USE `nanxiislet`; + +-- 获取系统管理菜单ID +SET @system_id = (SELECT id FROM sys_menu WHERE code = 'system'); + +-- 添加部门管理菜单(在角色管理之后) +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(@system_id, '部门管理', 'system-dept', 'menu', '/system/dept', 'ClusterOutlined', 3); + +-- 更新其他菜单排序 +UPDATE `sys_menu` SET `sort` = 4 WHERE `code` = 'system-menus'; +UPDATE `sys_menu` SET `sort` = 5 WHERE `code` = 'system-dict'; +UPDATE `sys_menu` SET `sort` = 6 WHERE `code` = 'system-approval'; +UPDATE `sys_menu` SET `sort` = 7 WHERE `code` = 'system-approval-instances'; + +-- 为超级管理员分配新菜单 +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT 1, id FROM `sys_menu` WHERE `code` = 'system-dept'; diff --git a/src/main/resources/db/V6__domain_enhance.sql b/src/main/resources/db/V6__domain_enhance.sql new file mode 100644 index 0000000..9549092 --- /dev/null +++ b/src/main/resources/db/V6__domain_enhance.sql @@ -0,0 +1,20 @@ +-- ================================== +-- 域名表增强 - 增加 1Panel 相关字段 +-- 创建日期: 2026-01-13 +-- ================================== + +USE `nanxiislet`; + +-- 添加 1Panel 相关字段 +ALTER TABLE `platform_domain` +ADD COLUMN `panel_website_id` BIGINT DEFAULT NULL COMMENT '1Panel网站ID' AFTER `force_https`, +ADD COLUMN `panel_ssl_id` BIGINT DEFAULT NULL COMMENT '1Panel证书ID' AFTER `panel_website_id`, +ADD COLUMN `site_path` VARCHAR(255) DEFAULT NULL COMMENT '网站目录路径' AFTER `panel_ssl_id`, +ADD COLUMN `alias` VARCHAR(100) DEFAULT NULL COMMENT '网站别名' AFTER `site_path`, +ADD COLUMN `deploy_status` VARCHAR(20) DEFAULT 'not_deployed' COMMENT '部署状态 not_deployed/deploying/deployed/failed' AFTER `alias`, +ADD COLUMN `last_deploy_time` DATETIME DEFAULT NULL COMMENT '最后部署时间' AFTER `deploy_status`, +ADD COLUMN `last_deploy_message` VARCHAR(500) DEFAULT NULL COMMENT '最后部署消息' AFTER `last_deploy_time`; + +-- 添加索引 +ALTER TABLE `platform_domain` ADD INDEX `idx_deploy_status` (`deploy_status`); +ALTER TABLE `platform_domain` ADD INDEX `idx_panel_website_id` (`panel_website_id`); diff --git a/src/main/resources/db/V7__certificate.sql b/src/main/resources/db/V7__certificate.sql new file mode 100644 index 0000000..8718ec2 --- /dev/null +++ b/src/main/resources/db/V7__certificate.sql @@ -0,0 +1,47 @@ +-- ============================================ +-- 证书管理表 +-- 创建日期: 2026-01-13 +-- ============================================ + +USE nanxiislet; + +-- 平台证书表 +DROP TABLE IF EXISTS `platform_certificate`; +CREATE TABLE `platform_certificate` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `server_id` BIGINT NOT NULL COMMENT '关联服务器ID', + `server_name` VARCHAR(100) DEFAULT NULL COMMENT '服务器名称', + `panel_ssl_id` BIGINT DEFAULT NULL COMMENT '1Panel证书ID', + `primary_domain` VARCHAR(200) NOT NULL COMMENT '主域名', + `other_domains` VARCHAR(500) DEFAULT NULL COMMENT '其他域名(逗号分隔)', + `cn` VARCHAR(100) DEFAULT NULL COMMENT '证书主体名称', + `organization` VARCHAR(200) DEFAULT NULL COMMENT '颁发组织', + `provider` VARCHAR(50) DEFAULT 'dnsAccount' COMMENT '验证方式 dnsAccount/httpManual', + `acme_account_id` BIGINT DEFAULT NULL COMMENT 'Acme账户ID', + `acme_account_email` VARCHAR(200) DEFAULT NULL COMMENT 'Acme账户邮箱', + `dns_account_id` BIGINT DEFAULT NULL COMMENT 'DNS账户ID', + `dns_account_name` VARCHAR(100) DEFAULT NULL COMMENT 'DNS账户名称', + `dns_account_type` VARCHAR(50) DEFAULT NULL COMMENT 'DNS账户类型', + `key_type` VARCHAR(50) DEFAULT 'P256' COMMENT '密钥算法 P256/P384/RSA2048/RSA4096', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/valid/expired/error', + `auto_renew` TINYINT(1) DEFAULT 1 COMMENT '自动续签', + `start_date` DATE DEFAULT NULL COMMENT '生效时间', + `expire_date` DATE DEFAULT NULL COMMENT '过期时间', + `cert_content` TEXT DEFAULT NULL COMMENT '证书内容', + `key_content` TEXT DEFAULT NULL COMMENT '私钥内容', + `description` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_server_id` (`server_id`), + KEY `idx_panel_ssl_id` (`panel_ssl_id`), + KEY `idx_primary_domain` (`primary_domain`), + KEY `idx_status` (`status`), + KEY `idx_expire_date` (`expire_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台证书表'; + +SELECT '证书表创建完成!' AS Result; diff --git a/src/main/resources/db/V8__add_runtime_to_domain.sql b/src/main/resources/db/V8__add_runtime_to_domain.sql new file mode 100644 index 0000000..ef7c7ff --- /dev/null +++ b/src/main/resources/db/V8__add_runtime_to_domain.sql @@ -0,0 +1,12 @@ +-- ================================== +-- 域名表增强 - 增加运行环境相关字段 +-- 创建日期: 2026-01-19 +-- ================================== + +USE `nanxiislet`; + +-- 添加运行环境相关字段 +ALTER TABLE `platform_domain` +ADD COLUMN `runtime_id` BIGINT DEFAULT NULL COMMENT '关联运行环境ID', +ADD COLUMN `runtime_server_id` BIGINT DEFAULT NULL COMMENT '运行环境所属服务器ID', +ADD COLUMN `runtime_name` VARCHAR(100) DEFAULT NULL COMMENT '运行环境名称'; diff --git a/src/main/resources/db/fix_approval_tables.sql b/src/main/resources/db/fix_approval_tables.sql new file mode 100644 index 0000000..c8812e6 --- /dev/null +++ b/src/main/resources/db/fix_approval_tables.sql @@ -0,0 +1,179 @@ +-- ================================== +-- 紧急修复:创建部门和审批相关表 +-- 在数据库中执行此脚本 +-- ================================== + +USE `nanxiislet`; + +-- ================================== +-- 部门管理表 +-- ================================== +CREATE TABLE IF NOT EXISTS `sys_dept` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父部门ID,0表示顶级部门', + `name` VARCHAR(100) NOT NULL COMMENT '部门名称', + `code` VARCHAR(50) DEFAULT NULL COMMENT '部门编码', + `leader_id` BIGINT DEFAULT NULL COMMENT '部门负责人ID', + `leader_name` VARCHAR(50) DEFAULT NULL COMMENT '部门负责人名称', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_code` (`code`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门管理表'; + +-- 初始化部门数据(忽略重复) +INSERT IGNORE INTO `sys_dept` (`id`, `parent_id`, `name`, `code`, `sort`, `status`) VALUES +(1, 0, '南溪屿科技', 'ROOT', 0, 1), +(2, 1, '技术部', 'TECH', 1, 1), +(3, 1, '产品部', 'PRODUCT', 2, 1), +(4, 1, '财务部', 'FINANCE', 3, 1), +(5, 1, '人力资源部', 'HR', 4, 1), +(6, 2, '前端开发组', 'TECH-FE', 1, 1), +(7, 2, '后端开发组', 'TECH-BE', 2, 1), +(8, 2, '运维组', 'TECH-OPS', 3, 1); + +-- ================================== +-- 审批流程模板表 +-- ================================== +CREATE TABLE IF NOT EXISTS `sys_approval_template` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '模板名称', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用 0-禁用 1-启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_scenario` (`scenario`), + KEY `idx_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程模板表'; + +-- ================================== +-- 审批流程节点表 +-- ================================== +CREATE TABLE IF NOT EXISTS `sys_approval_node` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `name` VARCHAR(100) NOT NULL COMMENT '节点名称', + `approver_type` VARCHAR(20) NOT NULL DEFAULT 'specified' COMMENT '审批人类型', + `approver_ids` JSON DEFAULT NULL COMMENT '审批人ID列表', + `approver_role` VARCHAR(50) DEFAULT NULL COMMENT '审批角色编码', + `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `dept_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `approval_mode` VARCHAR(10) DEFAULT 'or' COMMENT '审批方式: and-会签 or-或签', + `timeout_hours` INT DEFAULT NULL COMMENT '超时时间(小时)', + `timeout_action` VARCHAR(20) DEFAULT NULL COMMENT '超时操作', + `sort` INT DEFAULT 0 COMMENT '节点顺序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程节点表'; + +-- ================================== +-- 审批实例表 +-- ================================== +CREATE TABLE IF NOT EXISTS `sys_approval_instance` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `template_name` VARCHAR(100) DEFAULT NULL COMMENT '模板名称', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景', + `business_type` VARCHAR(50) NOT NULL COMMENT '业务类型', + `business_id` BIGINT NOT NULL COMMENT '业务ID', + `business_title` VARCHAR(200) DEFAULT NULL COMMENT '业务标题', + `initiator_id` BIGINT NOT NULL COMMENT '发起人ID', + `initiator_name` VARCHAR(50) DEFAULT NULL COMMENT '发起人名称', + `initiator_avatar` VARCHAR(255) DEFAULT NULL COMMENT '发起人头像', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态', + `current_node_id` BIGINT DEFAULT NULL COMMENT '当前节点ID', + `current_node_name` VARCHAR(100) DEFAULT NULL COMMENT '当前节点名称', + `submitted_at` DATETIME DEFAULT NULL COMMENT '提交时间', + `completed_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`), + KEY `idx_business` (`business_type`, `business_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批实例表'; + +-- ================================== +-- 审批记录表 +-- ================================== +CREATE TABLE IF NOT EXISTS `sys_approval_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `instance_id` BIGINT NOT NULL COMMENT '实例ID', + `node_id` BIGINT NOT NULL COMMENT '节点ID', + `node_name` VARCHAR(100) DEFAULT NULL COMMENT '节点名称', + `approver_id` BIGINT NOT NULL COMMENT '审批人ID', + `approver_name` VARCHAR(50) DEFAULT NULL COMMENT '审批人名称', + `approver_avatar` VARCHAR(255) DEFAULT NULL COMMENT '审批人头像', + `action` VARCHAR(20) NOT NULL COMMENT '操作', + `comment` VARCHAR(500) DEFAULT NULL COMMENT '审批意见', + `operated_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_instance_id` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批记录表'; + +-- 初始化审批流程模板示例数据(忽略重复) +INSERT IGNORE INTO `sys_approval_template` (`name`, `description`, `scenario`, `enabled`) VALUES +('费用报销审批', '员工费用报销审批流程', 'expense_reimbursement', 1), +('付款申请审批', '付款申请审批流程', 'payment_request', 1), +('项目发布审批', '项目发布前的审批流程', 'project_publish', 1); + +-- 添加部门管理菜单(如果不存在) +SET @system_id = (SELECT id FROM sys_menu WHERE code = 'system' LIMIT 1); +INSERT IGNORE INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `icon`, `sort`) VALUES +(@system_id, '部门管理', 'system-dept', 'menu', '/system/dept', 'ClusterOutlined', 3); + +-- 为超级管理员分配菜单 +INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT 1, id FROM `sys_menu` WHERE `code` = 'system-dept'; + +-- 为用户表添加部门字段(使用存储过程检查列是否存在) +SET @dbname = DATABASE(); +SET @tablename = 'sys_user'; + +-- 检查并添加 dept_id 列 +SET @columnname = 'dept_id'; +SET @preparedStatement = (SELECT IF( + EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname + ), + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` BIGINT DEFAULT NULL COMMENT ''部门ID'' AFTER `role`') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +-- 检查并添加 dept_name 列 +SET @columnname = 'dept_name'; +SET @preparedStatement = (SELECT IF( + EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname + ), + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` VARCHAR(100) DEFAULT NULL COMMENT ''部门名称'' AFTER `dept_id`') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +SELECT 'SQL执行完成!请重启后端服务。' AS message; diff --git a/src/main/resources/db/fix_project_table.sql b/src/main/resources/db/fix_project_table.sql new file mode 100644 index 0000000..346020b --- /dev/null +++ b/src/main/resources/db/fix_project_table.sql @@ -0,0 +1,22 @@ +-- ============================================ +-- 修复 platform_project 表结构 +-- 添加缺失的列(忽略已存在的列错误) +-- ============================================ + +USE nanxiislet; + +-- 添加缺失的字段(如列已存在会报错,可忽略) +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS short_name VARCHAR(50) COMMENT '项目简称'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS project_group VARCHAR(50) DEFAULT 'default' COMMENT '项目分组'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS logo VARCHAR(50) COMMENT 'Logo文字'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS color VARCHAR(50) COMMENT '主题色'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS domain VARCHAR(200) COMMENT '绑定域名'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS remark VARCHAR(500) COMMENT '备注'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS enable_https TINYINT(1) DEFAULT 0 COMMENT '是否启用HTTPS'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS panel_website_id BIGINT COMMENT '1Panel网站ID'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS panel_ssl_id BIGINT COMMENT '1Panel证书ID'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS last_deploy_time DATETIME COMMENT '最后部署时间'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS last_deploy_status VARCHAR(50) COMMENT '最后部署状态'; +ALTER TABLE platform_project ADD COLUMN IF NOT EXISTS last_deploy_message VARCHAR(500) COMMENT '最后部署消息'; + +SELECT 'platform_project 表修复完成' AS Result; diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql new file mode 100644 index 0000000..da4076b --- /dev/null +++ b/src/main/resources/db/init.sql @@ -0,0 +1,418 @@ +-- ================================== +-- Nanxiislet Admin 数据库初始化脚本 +-- 创建日期: 2026-01-06 +-- ================================== + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS `nanxiislet` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE `nanxiislet`; + +-- ================================== +-- 系统用户表 +-- ================================== +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '密码', + `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `role` VARCHAR(50) DEFAULT 'user' COMMENT '角色编码', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_phone` (`phone`), + KEY `idx_email` (`email`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统用户表'; + +-- ================================== +-- 平台服务器表 +-- ================================== +DROP TABLE IF EXISTS `platform_server`; +CREATE TABLE `platform_server` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '服务器名称', + `ip` VARCHAR(50) NOT NULL COMMENT '公网IP', + `internal_ip` VARCHAR(50) DEFAULT NULL COMMENT '内网IP', + `port` INT DEFAULT 22 COMMENT 'SSH端口', + `type` VARCHAR(20) DEFAULT 'cloud' COMMENT '服务器类型 physical/virtual/cloud', + `status` VARCHAR(20) DEFAULT 'offline' COMMENT '状态 online/offline/warning/maintenance', + `os` VARCHAR(100) DEFAULT NULL COMMENT '操作系统', + `cpu_cores` INT DEFAULT NULL COMMENT 'CPU核心数', + `memory_total` INT DEFAULT NULL COMMENT '内存大小(GB)', + `disk_total` INT DEFAULT NULL COMMENT '磁盘大小(GB)', + `tags` JSON DEFAULT NULL COMMENT '标签', + `ssh_user` VARCHAR(50) DEFAULT 'root' COMMENT 'SSH用户名', + `ssh_port` INT DEFAULT 22 COMMENT 'SSH端口', + `panel_url` VARCHAR(255) DEFAULT NULL COMMENT '1Panel面板地址', + `panel_port` INT DEFAULT NULL COMMENT '1Panel面板端口', + `panel_api_key` VARCHAR(255) DEFAULT NULL COMMENT '1Panel API密钥', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_ip` (`ip`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台服务器表'; + +-- ================================== +-- 平台项目表 +-- ================================== +DROP TABLE IF EXISTS `platform_project`; +CREATE TABLE `platform_project` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '项目名称', + `code` VARCHAR(50) DEFAULT NULL COMMENT '项目编码', + `type` VARCHAR(50) DEFAULT NULL COMMENT '项目类型', + `url` VARCHAR(255) DEFAULT NULL COMMENT '访问地址', + `domain_id` BIGINT DEFAULT NULL COMMENT '关联域名ID', + `server_id` BIGINT DEFAULT NULL COMMENT '关联服务器ID', + `server_name` VARCHAR(100) DEFAULT NULL COMMENT '服务器名称', + `deploy_path` VARCHAR(255) DEFAULT NULL COMMENT '部署路径', + `version` VARCHAR(50) DEFAULT NULL COMMENT '当前版本', + `status` VARCHAR(20) DEFAULT 'inactive' COMMENT '状态 active/inactive/deploying', + `icon` VARCHAR(255) DEFAULT NULL COMMENT '图标', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `sort` INT DEFAULT 0 COMMENT '排序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_code` (`code`), + KEY `idx_server_id` (`server_id`), + KEY `idx_domain_id` (`domain_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台项目表'; + +-- ================================== +-- 平台域名表 +-- ================================== +DROP TABLE IF EXISTS `platform_domain`; +CREATE TABLE `platform_domain` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `domain` VARCHAR(255) NOT NULL COMMENT '域名', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `server_id` BIGINT DEFAULT NULL COMMENT '关联服务器ID', + `server_name` VARCHAR(100) DEFAULT NULL COMMENT '关联服务器名称', + `server_ip` VARCHAR(50) DEFAULT NULL COMMENT '服务器IP', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 active/pending/expired/error', + `dns_status` VARCHAR(20) DEFAULT 'checking' COMMENT 'DNS状态 resolved/unresolved/checking', + `dns_records` JSON DEFAULT NULL COMMENT 'DNS记录', + `ssl_status` VARCHAR(20) DEFAULT 'none' COMMENT 'SSL状态 valid/expiring/expired/none', + `ssl_expire_date` DATE DEFAULT NULL COMMENT 'SSL过期时间', + `certificate_id` VARCHAR(100) DEFAULT NULL COMMENT '证书ID', + `certificate_name` VARCHAR(100) DEFAULT NULL COMMENT '证书名称', + `nginx_config_path` VARCHAR(255) DEFAULT NULL COMMENT 'Nginx配置路径', + `proxy_pass` VARCHAR(255) DEFAULT NULL COMMENT '代理地址', + `port` INT DEFAULT 80 COMMENT '端口', + `enable_https` TINYINT DEFAULT 0 COMMENT '是否启用HTTPS', + `force_https` TINYINT DEFAULT 0 COMMENT '是否强制HTTPS', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_domain` (`domain`), + KEY `idx_project_id` (`project_id`), + KEY `idx_server_id` (`server_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台域名表'; + +-- ================================== +-- 财务账户表 +-- ================================== +DROP TABLE IF EXISTS `finance_account`; +CREATE TABLE `finance_account` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '账户名称', + `type` VARCHAR(20) DEFAULT 'corporate' COMMENT '账户类型 corporate-对公账户 merchant-商户号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '开户银行', + `bank_branch` VARCHAR(100) DEFAULT NULL COMMENT '开户支行', + `account_no` VARCHAR(50) DEFAULT NULL COMMENT '银行账号', + `merchant_id` VARCHAR(100) DEFAULT NULL COMMENT '商户ID', + `merchant_platform` VARCHAR(20) DEFAULT NULL COMMENT '商户平台 wechat/alipay/unionpay/other', + `app_id` VARCHAR(100) DEFAULT NULL COMMENT 'AppID', + `balance` DECIMAL(15,2) DEFAULT 0.00 COMMENT '当前余额', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态 active-正常 inactive-禁用', + `is_default` TINYINT DEFAULT 0 COMMENT '是否默认账户', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务账户表'; + +-- ================================== +-- 财务收入表 +-- ================================== +DROP TABLE IF EXISTS `finance_income`; +CREATE TABLE `finance_income` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `income_no` VARCHAR(50) NOT NULL COMMENT '收入编号', + `type` VARCHAR(30) DEFAULT 'project' COMMENT '收入类型 project/service_fee/consulting/commission/other', + `title` VARCHAR(200) NOT NULL COMMENT '收入名称', + `customer_id` BIGINT DEFAULT NULL COMMENT '客户ID', + `customer_name` VARCHAR(100) DEFAULT NULL COMMENT '客户名称', + `customer_contact` VARCHAR(100) DEFAULT NULL COMMENT '客户联系方式', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `contract_no` VARCHAR(50) DEFAULT NULL COMMENT '合同编号', + `total_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '总金额', + `received_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '已收金额', + `pending_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '待收金额', + `account_id` BIGINT DEFAULT NULL COMMENT '收款账户ID', + `account_name` VARCHAR(100) DEFAULT NULL COMMENT '收款账户名称', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/partial/received/overdue', + `expected_date` DATE DEFAULT NULL COMMENT '预计收款日期', + `actual_date` DATE DEFAULT NULL COMMENT '实际收款日期', + `invoice_required` TINYINT DEFAULT 0 COMMENT '是否需要发票', + `invoice_issued` TINYINT DEFAULT 0 COMMENT '发票是否已开', + `invoice_no` VARCHAR(50) DEFAULT NULL COMMENT '发票号', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_income_no` (`income_no`), + KEY `idx_type` (`type`), + KEY `idx_customer_id` (`customer_id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_status` (`status`), + KEY `idx_expected_date` (`expected_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务收入表'; + +-- ================================== +-- 财务支出表 +-- ================================== +DROP TABLE IF EXISTS `finance_expense`; +CREATE TABLE `finance_expense` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `expense_no` VARCHAR(50) NOT NULL COMMENT '支出编号', + `type` VARCHAR(30) DEFAULT 'other' COMMENT '支出类型 salary/office/rent/travel/marketing/equipment/service/tax/social_insurance/other', + `title` VARCHAR(200) NOT NULL COMMENT '支出名称', + `payee_name` VARCHAR(100) DEFAULT NULL COMMENT '收款方名称', + `payee_account` VARCHAR(50) DEFAULT NULL COMMENT '收款方账号', + `payee_bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款方开户行', + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '金额', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `account_id` BIGINT DEFAULT NULL COMMENT '付款账户ID', + `account_name` VARCHAR(100) DEFAULT NULL COMMENT '付款账户名称', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` BIGINT DEFAULT NULL COMMENT '审批流程ID', + `approval_status` VARCHAR(20) DEFAULT NULL COMMENT '审批状态', + `attachments` JSON DEFAULT NULL COMMENT '附件路径', + `payment_date` DATE DEFAULT NULL COMMENT '付款日期', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_expense_no` (`expense_no`), + KEY `idx_type` (`type`), + KEY `idx_project_id` (`project_id`), + KEY `idx_department_id` (`department_id`), + KEY `idx_status` (`status`), + KEY `idx_payment_date` (`payment_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务支出表'; + +-- ================================== +-- 财务预算表 +-- ================================== +DROP TABLE IF EXISTS `finance_budget`; +CREATE TABLE `finance_budget` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '预算名称', + `period` VARCHAR(20) DEFAULT 'monthly' COMMENT '预算周期 monthly/quarterly/yearly', + `year` INT NOT NULL COMMENT '年份', + `quarter` INT DEFAULT NULL COMMENT '季度 1-4', + `month` INT DEFAULT NULL COMMENT '月份 1-12', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `project_id` BIGINT DEFAULT NULL COMMENT '项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '项目名称', + `items` JSON DEFAULT NULL COMMENT '预算明细', + `total_budget` DECIMAL(15,2) DEFAULT 0.00 COMMENT '总预算', + `used_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '已使用金额', + `remaining_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '剩余金额', + `usage_rate` DECIMAL(5,2) DEFAULT 0.00 COMMENT '使用率 0-100', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/active/completed/cancelled', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_year` (`year`), + KEY `idx_period` (`period`), + KEY `idx_department_id` (`department_id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务预算表'; + +-- ================================== +-- 财务报销表 +-- ================================== +DROP TABLE IF EXISTS `finance_reimbursement`; +CREATE TABLE `finance_reimbursement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `reimbursement_no` VARCHAR(50) NOT NULL COMMENT '报销单号', + `type` VARCHAR(30) DEFAULT 'other' COMMENT '报销类型 travel/meal/transport/communication/office/other', + `title` VARCHAR(200) NOT NULL COMMENT '报销标题', + `applicant_id` BIGINT DEFAULT NULL COMMENT '申请人ID', + `applicant_name` VARCHAR(50) DEFAULT NULL COMMENT '申请人名称', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `total_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '总金额', + `items` JSON DEFAULT NULL COMMENT '报销明细', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` BIGINT DEFAULT NULL COMMENT '审批流程ID', + `approval_status` VARCHAR(20) DEFAULT NULL COMMENT '审批状态', + `current_approver` VARCHAR(50) DEFAULT NULL COMMENT '当前审批人', + `bank_account_name` VARCHAR(100) DEFAULT NULL COMMENT '收款人银行户名', + `bank_account_no` VARCHAR(50) DEFAULT NULL COMMENT '收款人银行账号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款人开户行', + `payment_account_id` BIGINT DEFAULT NULL COMMENT '付款账户ID', + `payment_date` DATE DEFAULT NULL COMMENT '付款日期', + `payment_remark` VARCHAR(500) DEFAULT NULL COMMENT '付款备注', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_reimbursement_no` (`reimbursement_no`), + KEY `idx_type` (`type`), + KEY `idx_applicant_id` (`applicant_id`), + KEY `idx_department_id` (`department_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务报销表'; + +-- ================================== +-- 财务发票表 +-- ================================== +DROP TABLE IF EXISTS `finance_invoice`; +CREATE TABLE `finance_invoice` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `settlement_id` BIGINT DEFAULT NULL COMMENT '关联结算单ID', + `type` VARCHAR(20) DEFAULT 'vat_normal' COMMENT '发票类型 vat_special/vat_normal/personal', + `title` VARCHAR(200) NOT NULL COMMENT '发票抬头', + `tax_code` VARCHAR(50) DEFAULT NULL COMMENT '税号', + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '金额', + `file_url` VARCHAR(255) DEFAULT NULL COMMENT '电子发票文件URL', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/issued/rejected', + `submit_time` DATE DEFAULT NULL COMMENT '提交时间', + `issue_time` DATE DEFAULT NULL COMMENT '开票时间', + `reject_reason` VARCHAR(500) DEFAULT NULL COMMENT '驳回原因', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_settlement_id` (`settlement_id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务发票表'; + +-- ================================== +-- 财务结算表 +-- ================================== +DROP TABLE IF EXISTS `finance_settlement`; +CREATE TABLE `finance_settlement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `project_id` BIGINT DEFAULT NULL COMMENT '项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '项目名称', + `talent_id` BIGINT DEFAULT NULL COMMENT '人才ID', + `talent_name` VARCHAR(50) DEFAULT NULL COMMENT '人才名称', + `period` VARCHAR(20) DEFAULT NULL COMMENT '结算周期/月份', + `total_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '项目总额', + `platform_fee` DECIMAL(15,2) DEFAULT 0.00 COMMENT '平台服务费', + `taxable_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '应纳税所得额', + `tax_rate` DECIMAL(5,4) DEFAULT 0.0000 COMMENT '税率', + `tax_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '扣税金额', + `actual_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '实发金额', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/paying/completed/rejected', + `invoice_status` VARCHAR(20) DEFAULT 'none' COMMENT '发票状态 none/pending/received', + `bank_account_name` VARCHAR(100) DEFAULT NULL COMMENT '银行户名', + `bank_account_no` VARCHAR(50) DEFAULT NULL COMMENT '银行账号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '开户银行', + `audit_time` DATE DEFAULT NULL COMMENT '审核时间', + `payment_time` DATE DEFAULT NULL COMMENT '打款时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_talent_id` (`talent_id`), + KEY `idx_period` (`period`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务结算表'; + +-- ================================== +-- 初始化管理员账户 +-- 密码: admin123 (BCrypt加密) +-- ================================== +INSERT INTO `sys_user` (`username`, `password`, `nickname`, `role`, `status`, `remark`) +VALUES ('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '超级管理员', 'super_admin', 1, '系统初始管理员'); + +-- ================================== +-- 初始化示例数据 +-- ================================== + +-- 服务器示例数据 +INSERT INTO `platform_server` (`name`, `ip`, `internal_ip`, `port`, `type`, `status`, `os`, `cpu_cores`, `memory_total`, `disk_total`, `ssh_user`, `ssh_port`, `panel_port`, `panel_api_key`, `description`) +VALUES +('生产服务器', '47.109.57.58', NULL, 22, 'cloud', 'online', 'CentOS 7.9', 4, 8, 100, 'root', 22, 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', '南溪小岛主生产服务器,部署1Panel面板'); + +-- 项目示例数据 +INSERT INTO `platform_project` (`name`, `code`, `type`, `status`, `version`, `description`, `sort`) +VALUES +('南溪屿后台管理', 'nanxiislet-admin', 'frontend', 'active', '1.0.0', '南溪屿后台管理系统前端', 1), +('南溪屿API服务', 'nanxiislet-api', 'backend', 'active', '1.0.0', '南溪屿后台管理系统API服务', 2); + +-- 账户示例数据 +INSERT INTO `finance_account` (`name`, `type`, `bank_name`, `bank_branch`, `account_no`, `balance`, `status`, `is_default`, `remark`) +VALUES +('公司对公账户', 'corporate', '招商银行', '上海分行', '6226****1234', 100000.00, 'active', 1, '公司主账户'); diff --git a/src/main/resources/db/init_domain_data.sql b/src/main/resources/db/init_domain_data.sql new file mode 100644 index 0000000..4580e20 --- /dev/null +++ b/src/main/resources/db/init_domain_data.sql @@ -0,0 +1,20 @@ +-- ============================================ +-- 初始化域名数据 +-- ============================================ + +USE nanxiislet; + +-- 插入示例域名数据 +INSERT INTO platform_domain (domain, server_id, server_name, server_ip, status, dns_status, ssl_status, port, enable_https, force_https, description, deploy_status) +SELECT 'codeport.nanxiislet.com', 1, '生产服务器', '47.109.57.58', 'active', 'resolved', 'valid', 443, 1, 1, 'CodePort 码头平台域名', 'deployed' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM platform_domain WHERE domain = 'codeport.nanxiislet.com'); + +INSERT INTO platform_domain (domain, server_id, server_name, server_ip, status, dns_status, ssl_status, port, enable_https, force_https, description, deploy_status) +SELECT 'admin.nanxiislet.com', 1, '生产服务器', '47.109.57.58', 'active', 'resolved', 'valid', 443, 1, 1, '南溪屿后台管理系统', 'deployed' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM platform_domain WHERE domain = 'admin.nanxiislet.com'); + +INSERT INTO platform_domain (domain, server_id, server_name, server_ip, status, dns_status, ssl_status, port, enable_https, force_https, description, deploy_status) +SELECT 'api.nanxiislet.com', 1, '生产服务器', '47.109.57.58', 'pending', 'checking', 'none', 80, 0, 0, 'API服务域名', 'not_deployed' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM platform_domain WHERE domain = 'api.nanxiislet.com'); + +SELECT '域名数据初始化完成!' AS Result; diff --git a/src/main/resources/db/init_domain_dictionaries.sql b/src/main/resources/db/init_domain_dictionaries.sql new file mode 100644 index 0000000..485831f --- /dev/null +++ b/src/main/resources/db/init_domain_dictionaries.sql @@ -0,0 +1,38 @@ +-- ================================== +-- 初始化域名相关字典数据 +-- 包含:域名状态、DNS状态、SSL状态 +-- 备注字段(remark)用于存储前端显示的颜色值 +-- ================================== + +USE `nanxiislet`; + +-- 1. 插入字典类型 +INSERT INTO `sys_dict` (`name`, `code`, `remark`) VALUES +('域名状态', 'domain_status', '域名部署及运行状态'), +('DNS状态', 'dns_status', 'DNS解析状态'), +('SSL状态', 'ssl_status', 'SSL证书状态'); + +-- 2. 插入字典项 + +-- 域名状态 (domain_status) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'domain_status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '正常', 'active', 1, 'green'), +(@dict_id, '待配置', 'pending', 2, 'orange'), +(@dict_id, '已过期', 'expired', 3, 'red'), +(@dict_id, '异常', 'error', 4, 'red'); + +-- DNS状态 (dns_status) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'dns_status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '已解析', 'resolved', 1, 'green'), +(@dict_id, '未解析', 'unresolved', 2, 'red'), +(@dict_id, '检测中', 'checking', 3, 'blue'); + +-- SSL状态 (ssl_status) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'ssl_status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '正常', 'valid', 1, 'green'), +(@dict_id, '即将过期', 'expiring', 2, 'orange'), +(@dict_id, '已过期', 'expired', 3, 'red'), +(@dict_id, '未配置', 'none', 4, 'default'); diff --git a/src/main/resources/db/init_platform_server.sql b/src/main/resources/db/init_platform_server.sql new file mode 100644 index 0000000..17fdfe7 --- /dev/null +++ b/src/main/resources/db/init_platform_server.sql @@ -0,0 +1,83 @@ +-- ============================================ +-- 平台服务器管理 - 数据库初始化脚本 +-- ============================================ + +-- 创建 platform_server 表 +CREATE TABLE IF NOT EXISTS `platform_server` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '服务器名称', + `ip` VARCHAR(50) NOT NULL COMMENT '公网IP', + `internal_ip` VARCHAR(50) COMMENT '内网IP', + `port` INT DEFAULT 22 COMMENT 'SSH端口', + `type` VARCHAR(20) DEFAULT 'cloud' COMMENT '服务器类型 physical/virtual/cloud', + `status` VARCHAR(20) DEFAULT 'offline' COMMENT '状态 online/offline/warning/maintenance', + `os` VARCHAR(100) COMMENT '操作系统', + `cpu_cores` INT COMMENT 'CPU核心数', + `memory_total` INT COMMENT '内存大小(GB)', + `disk_total` INT COMMENT '磁盘大小(GB)', + `tags` VARCHAR(500) COMMENT '标签(JSON格式)', + `ssh_user` VARCHAR(50) DEFAULT 'root' COMMENT 'SSH用户名', + `ssh_port` INT DEFAULT 22 COMMENT 'SSH端口', + `panel_url` VARCHAR(200) COMMENT '1Panel面板地址', + `panel_port` INT DEFAULT 42588 COMMENT '1Panel面板端口', + `panel_api_key` VARCHAR(200) COMMENT '1Panel API密钥', + `description` VARCHAR(500) COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` VARCHAR(50) COMMENT '创建人', + `updated_by` VARCHAR(50) COMMENT '更新人', + `deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ip` (`ip`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台服务器表'; + +-- ============================================ +-- 插入生产服务器数据 (47.109.57.58) +-- ============================================ +INSERT INTO `platform_server` ( + `id`, + `name`, + `ip`, + `internal_ip`, + `port`, + `type`, + `status`, + `os`, + `cpu_cores`, + `memory_total`, + `disk_total`, + `tags`, + `ssh_user`, + `ssh_port`, + `panel_url`, + `panel_port`, + `panel_api_key`, + `description` +) VALUES ( + 1, + '生产服务器', + '47.109.57.58', + NULL, + 22, + 'cloud', + 'online', + 'CentOS 7.9', + 4, + 8, + 100, + '["生产", "主节点"]', + 'root', + 22, + 'http://47.109.57.58', + 42588, + 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', + '南溪小岛主生产服务器,部署1Panel面板' +) ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `panel_port` = VALUES(`panel_port`), + `panel_api_key` = VALUES(`panel_api_key`), + `status` = VALUES(`status`), + `updated_at` = CURRENT_TIMESTAMP; + +-- 验证数据 +SELECT * FROM `platform_server` WHERE `id` = 1; diff --git a/src/main/resources/db/init_server_data.sql b/src/main/resources/db/init_server_data.sql new file mode 100644 index 0000000..a86e78d --- /dev/null +++ b/src/main/resources/db/init_server_data.sql @@ -0,0 +1,52 @@ +-- ============================================ +-- 初始化生产服务器数据 (47.109.57.58) +-- ============================================ + +-- 插入或更新服务器数据 +INSERT INTO platform_server ( + id, + name, + ip, + port, + type, + status, + os, + cpu_cores, + memory_total, + disk_total, + tags, + ssh_user, + ssh_port, + panel_port, + panel_api_key, + description, + created_at, + updated_at +) VALUES ( + 1, + '生产服务器', + '47.109.57.58', + 22, + 'cloud', + 'online', + 'CentOS 7.9', + 4, + 8, + 100, + '["生产", "主节点"]', + 'root', + 22, + 42588, + 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', + '南溪小岛主生产服务器,部署1Panel面板', + NOW(), + NOW() +) ON DUPLICATE KEY UPDATE + name = VALUES(name), + panel_port = VALUES(panel_port), + panel_api_key = VALUES(panel_api_key), + status = VALUES(status), + updated_at = NOW(); + +-- 验证数据 +SELECT id, name, ip, panel_port, panel_api_key, status FROM platform_server WHERE id = 1; diff --git a/src/main/resources/db/init_server_dictionaries.sql b/src/main/resources/db/init_server_dictionaries.sql new file mode 100644 index 0000000..b483d89 --- /dev/null +++ b/src/main/resources/db/init_server_dictionaries.sql @@ -0,0 +1,41 @@ +-- ================================== +-- 初始化服务器相关字典数据 +-- 包含:服务器标签、服务器状态、服务器类型 +-- 备注字段(remark)用于存储前端显示的颜色值 +-- ================================== + +USE `nanxiislet`; + +-- 1. 插入字典类型 +INSERT INTO `sys_dict` (`name`, `code`, `remark`) VALUES +('服务器标签', 'server_tag', '服务器用途分类'), +('服务器状态', 'server_status', '服务器运行状态'), +('服务器类型', 'server_type', '服务器硬件类型'); + +-- 2. 插入字典项 + +-- 服务器标签 (server_tag) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'server_tag'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '生产', '生产', 1, 'red'), +(@dict_id, '测试', '测试', 2, 'blue'), +(@dict_id, '开发', '开发', 3, 'green'), +(@dict_id, '备份', '备份', 4, 'purple'), +(@dict_id, '主节点', '主节点', 5, 'gold'), +(@dict_id, '从节点', '从节点', 6, 'cyan'), +(@dict_id, '离线', '离线', 7, 'default'); + +-- 服务器状态 (server_status) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'server_status'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '在线', 'online', 1, 'green'), +(@dict_id, '离线', 'offline', 2, 'default'), +(@dict_id, '告警', 'warning', 3, 'orange'), +(@dict_id, '维护中', 'maintenance', 4, 'blue'); + +-- 服务器类型 (server_type) +SET @dict_id = (SELECT id FROM sys_dict WHERE code = 'server_type'); +INSERT INTO `sys_dict_item` (`dict_id`, `label`, `value`, `sort`, `remark`) VALUES +(@dict_id, '云服务器', 'cloud', 1, 'default'), +(@dict_id, '物理机', 'physical', 2, 'default'), +(@dict_id, '虚拟机', 'virtual', 3, 'default'); diff --git a/src/main/resources/db/migration/V20260115_2__add_menu_project_id.sql b/src/main/resources/db/migration/V20260115_2__add_menu_project_id.sql new file mode 100644 index 0000000..b15b70f --- /dev/null +++ b/src/main/resources/db/migration/V20260115_2__add_menu_project_id.sql @@ -0,0 +1,6 @@ +-- 在 sys_menu 表中添加 project_id 字段 +ALTER TABLE sys_menu ADD COLUMN project_id BIGINT COMMENT '关联项目ID'; + +-- 这里的逻辑是:新加的字段默认为 NULL。 +-- 后续代码逻辑中,如果 project_id 为 NULL,可以视为属于主系统(楠溪框架)菜单, +-- 或者在绑定时,将现有菜单更新为特定项目的 ID。 diff --git a/src/main/resources/db/migration/V20260115_3__import_codeport_menus.sql b/src/main/resources/db/migration/V20260115_3__import_codeport_menus.sql new file mode 100644 index 0000000..aa5d02f --- /dev/null +++ b/src/main/resources/db/migration/V20260115_3__import_codeport_menus.sql @@ -0,0 +1,71 @@ +-- 导入 CodePort 项目菜单 (Project ID = 13) +-- 由系统自动生成 + +SET @pid = 13; + +-- 1. 控制台 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '控制台', 'cp_dashboard', 'menu', '/dashboard', 'dashboard/index', 'DashboardOutlined', 10, 0, 1, 0, NOW(), NOW()); + +-- 2. 社区管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '社区管理', 'cp_community', 'directory', '/community', NULL, 'TeamOutlined', 20, 0, 1, 0, NOW(), NOW()); +SET @dir_community = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_community, '帖子管理', 'cp_posts', 'menu', '/community/posts', 'community/posts/index', 'FileTextOutlined', 10, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_community, '评论管理', 'cp_comments', 'menu', '/community/comments', 'community/comments/index', 'MessageOutlined', 20, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_community, '标签管理', 'cp_tags', 'menu', '/community/tags', 'community/tags/index', 'TagsOutlined', 30, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_community, '城市圈子', 'cp_circles', 'menu', '/community/circles', 'community/circles/index', 'EnvironmentOutlined', 40, 0, 1, 0, NOW(), NOW()); + +-- 3. 客服管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '客服管理', 'cp_support', 'directory', '/support', NULL, 'CustomerServiceOutlined', 30, 0, 1, 0, NOW(), NOW()); +SET @dir_support = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_support, '接入会话', 'cp_support_console', 'menu', '/support/console', 'support/session/index', 'CommentOutlined', 10, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_support, '会话列表', 'cp_conversations', 'menu', '/support/conversations', 'support/conversations/index', 'UnorderedListOutlined', 20, 0, 1, 0, NOW(), NOW()); + +-- 4. 内容管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '内容管理', 'cp_content', 'directory', '/content', NULL, 'ReadOutlined', 40, 0, 1, 0, NOW(), NOW()); +SET @dir_content = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_content, '文章管理', 'cp_articles', 'menu', '/content/articles', 'content/articles/index', 'FileMarkdownOutlined', 10, 0, 1, 0, NOW(), NOW()); + +-- 5. 项目管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '项目管理', 'cp_project', 'directory', '/project', NULL, 'ProjectOutlined', 50, 0, 1, 0, NOW(), NOW()); +SET @dir_project = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_project, '项目列表', 'cp_projects', 'menu', '/project/list', 'project/list/index', 'UnorderedListOutlined', 10, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_project, '招募管理', 'cp_recruitment', 'menu', '/project/recruitment', 'project/recruitment/index', 'UserSwitchOutlined', 20, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_project, '已成交项目', 'cp_signed', 'menu', '/project/signed', 'project/signed/index', 'CheckCircleOutlined', 30, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_project, '合同管理', 'cp_contract', 'menu', '/project/contract', 'project/contract/index', 'FileProtectOutlined', 40, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_project, '会话管理', 'cp_proj_sessions', 'menu', '/project/sessions', 'project/session/index', 'CommentOutlined', 50, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_project, '会话详情', 'cp_proj_sess_detail', 'menu', '/project/sessions/:id', 'project/session/detail', '', 60, 1, 1, 0, NOW(), NOW()); + +-- 6. 人才管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '人才管理', 'cp_talent', 'directory', '/talent-mgr', NULL, 'UserOutlined', 60, 0, 1, 0, NOW(), NOW()); +SET @dir_talent = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_talent, '人才列表', 'cp_talent_list', 'menu', '/talent', 'talent/index', 'UserOutlined', 10, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_talent, '简历模板', 'cp_resume_tpl', 'menu', '/talent/resume-templates', 'talent/resume-templates', 'FileWordOutlined', 20, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_talent, '人才详情', 'cp_talent_detail', 'menu', '/talent/:id', 'talent/detail', '', 30, 1, 1, 0, NOW(), NOW()); + +-- 7. 用户管理 +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) +VALUES (@pid, 0, '用户管理', 'cp_user', 'directory', '/user', NULL, 'UsergroupAddOutlined', 70, 0, 1, 0, NOW(), NOW()); +SET @dir_user = LAST_INSERT_ID(); + +INSERT INTO sys_menu (project_id, parent_id, name, code, type, path, component, icon, sort, hidden, status, deleted, created_at, updated_at) VALUES +(@pid, @dir_user, '用户列表', 'cp_user_list', 'menu', '/user/list', 'user/list/index', 'UserOutlined', 10, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_user, '角色管理', 'cp_roles', 'menu', '/user/roles', 'user/roles/index', 'SafetyCertificateOutlined', 20, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_user, '认证管理', 'cp_certification', 'menu', '/user/certification', 'user/certification/index', 'IdcardOutlined', 30, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_user, '岗位管理', 'cp_positions', 'menu', '/user/positions', 'user/positions/index', 'SolutionOutlined', 40, 0, 1, 0, NOW(), NOW()), +(@pid, @dir_user, '等级配置', 'cp_levels', 'menu', '/user/levels', 'user/levels/index', 'StarOutlined', 50, 0, 1, 0, NOW(), NOW()); diff --git a/src/main/resources/db/migration/V20260115__add_project_system_type.sql b/src/main/resources/db/migration/V20260115__add_project_system_type.sql new file mode 100644 index 0000000..357dde4 --- /dev/null +++ b/src/main/resources/db/migration/V20260115__add_project_system_type.sql @@ -0,0 +1,4 @@ +-- 字段 system_type 已存在,故注释防止报错 +-- ALTER TABLE platform_project ADD COLUMN system_type VARCHAR(20) DEFAULT 'admin' COMMENT '系统类型 admin/portal'; +-- 字段 integrate_to_framework 已存在,故注释防止报错 +-- ALTER TABLE platform_project ADD COLUMN integrate_to_framework TINYINT(1) DEFAULT 0 COMMENT '是否集成到框架'; diff --git a/src/main/resources/db/migration/V20260117__add_database_menu.sql b/src/main/resources/db/migration/V20260117__add_database_menu.sql new file mode 100644 index 0000000..0452d55 --- /dev/null +++ b/src/main/resources/db/migration/V20260117__add_database_menu.sql @@ -0,0 +1,13 @@ +-- 添加数据库管理菜单 +-- 在平台管理下添加数据库管理菜单 + +-- 获取平台管理菜单的ID +SET @platform_id = (SELECT id FROM sys_menu WHERE code = 'platform' AND deleted = 0); + +-- 插入数据库管理菜单 +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `component`, `icon`, `sort`, `hidden`, `status`, `deleted`, `created_at`, `updated_at`) VALUES +(@platform_id, '数据库管理', 'platform-databases', 'menu', '/platform/databases', 'platform/databases/index', 'DatabaseOutlined', 5, 0, 1, 0, NOW(), NOW()); + +-- 为超级管理员分配此菜单权限 +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT 1, id FROM `sys_menu` WHERE code = 'platform-databases'; diff --git a/src/main/resources/db/migration/V20260118__add_runtime_menu.sql b/src/main/resources/db/migration/V20260118__add_runtime_menu.sql new file mode 100644 index 0000000..458cd3d --- /dev/null +++ b/src/main/resources/db/migration/V20260118__add_runtime_menu.sql @@ -0,0 +1,13 @@ +-- 添加运行环境管理菜单 +-- 在平台管理下添加运行环境管理菜单 + +-- 获取平台管理菜单的ID +SET @platform_id = (SELECT id FROM sys_menu WHERE code = 'platform' AND deleted = 0); + +-- 插入运行环境管理菜单 +INSERT INTO `sys_menu` (`parent_id`, `name`, `code`, `type`, `path`, `component`, `icon`, `sort`, `hidden`, `status`, `deleted`, `created_at`, `updated_at`) VALUES +(@platform_id, '运行环境', 'platform-runtimes', 'menu', '/platform/runtimes', 'platform/runtimes/index', 'CodeOutlined', 6, 0, 1, 0, NOW(), NOW()); + +-- 为超级管理员分配此菜单权限 +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT 1, id FROM `sys_menu` WHERE code = 'platform-runtimes'; diff --git a/src/main/resources/db/migration/nanxiislet.sql b/src/main/resources/db/migration/nanxiislet.sql new file mode 100644 index 0000000..681251b --- /dev/null +++ b/src/main/resources/db/migration/nanxiislet.sql @@ -0,0 +1,972 @@ +/* + Navicat Premium Data Transfer + + Source Server : nanxiJava + Source Server Type : MySQL + Source Server Version : 80407 + Source Host : 192.168.9.100:3306 + Source Schema : nanxiislet + + Target Server Type : MySQL + Target Server Version : 80407 + File Encoding : 65001 + + Date: 21/01/2026 21:08:35 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for finance_account +-- ---------------------------- +DROP TABLE IF EXISTS `finance_account`; +CREATE TABLE `finance_account` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '账户名称', + `type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'corporate' COMMENT '账户类型 corporate-对公账户 merchant-商户号', + `bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '开户银行', + `bank_branch` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '开户支行', + `account_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '银行账号', + `merchant_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商户ID', + `merchant_platform` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商户平台 wechat/alipay/unionpay/other', + `app_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'AppID', + `balance` decimal(15, 2) NULL COMMENT '当前余额', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'active' COMMENT '状态 active-正常 inactive-禁用', + `is_default` tinyint(0) NULL DEFAULT 0 COMMENT '是否默认账户', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_name`(`name`) USING BTREE, + INDEX `idx_type`(`type`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务账户表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_account +-- ---------------------------- +INSERT INTO `finance_account` VALUES (1, '公司对公账户', 'corporate', '招商银行', '上海分行', '6226****1234', NULL, NULL, NULL, 100000.00, 'active', 1, '公司主账户', '2026-01-10 18:37:46', '2026-01-10 18:37:46', NULL, NULL, 0); + +-- ---------------------------- +-- Table structure for finance_budget +-- ---------------------------- +DROP TABLE IF EXISTS `finance_budget`; +CREATE TABLE `finance_budget` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预算名称', + `period` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'monthly' COMMENT '预算周期 monthly/quarterly/yearly', + `year` int(0) NOT NULL COMMENT '年份', + `quarter` int(0) NULL DEFAULT NULL COMMENT '季度 1-4', + `month` int(0) NULL DEFAULT NULL COMMENT '月份 1-12', + `department_id` bigint(0) NULL DEFAULT NULL COMMENT '部门ID', + `department_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '项目ID', + `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '项目名称', + `items` json NULL COMMENT '预算明细', + `total_budget` decimal(15, 2) NULL COMMENT '总预算', + `used_amount` decimal(15, 2) NULL COMMENT '已使用金额', + `remaining_amount` decimal(15, 2) NULL COMMENT '剩余金额', + `usage_rate` decimal(5, 2) NULL COMMENT '使用率 0-100', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'draft' COMMENT '状态 draft/active/completed/cancelled', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_by_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人名称', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_year`(`year`) USING BTREE, + INDEX `idx_period`(`period`) USING BTREE, + INDEX `idx_department_id`(`department_id`) USING BTREE, + INDEX `idx_project_id`(`project_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务预算表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_budget +-- ---------------------------- + +-- ---------------------------- +-- Table structure for finance_expense +-- ---------------------------- +DROP TABLE IF EXISTS `finance_expense`; +CREATE TABLE `finance_expense` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `expense_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '支出编号', + `type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'other' COMMENT '支出类型 salary/office/rent/travel/marketing/equipment/service/tax/social_insurance/other', + `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '支出名称', + `payee_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款方名称', + `payee_account` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款方账号', + `payee_bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款方开户行', + `amount` decimal(15, 2) NOT NULL COMMENT '金额', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '关联项目ID', + `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联项目名称', + `department_id` bigint(0) NULL DEFAULT NULL COMMENT '部门ID', + `department_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称', + `account_id` bigint(0) NULL DEFAULT NULL COMMENT '付款账户ID', + `account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '付款账户名称', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` bigint(0) NULL DEFAULT NULL COMMENT '审批流程ID', + `approval_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批状态', + `attachments` json NULL COMMENT '附件路径', + `payment_date` date NULL DEFAULT NULL COMMENT '付款日期', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_by_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人名称', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_expense_no`(`expense_no`) USING BTREE, + INDEX `idx_type`(`type`) USING BTREE, + INDEX `idx_project_id`(`project_id`) USING BTREE, + INDEX `idx_department_id`(`department_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE, + INDEX `idx_payment_date`(`payment_date`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务支出表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_expense +-- ---------------------------- + +-- ---------------------------- +-- Table structure for finance_income +-- ---------------------------- +DROP TABLE IF EXISTS `finance_income`; +CREATE TABLE `finance_income` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `income_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收入编号', + `type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'project' COMMENT '收入类型 project/service_fee/consulting/commission/other', + `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收入名称', + `customer_id` bigint(0) NULL DEFAULT NULL COMMENT '客户ID', + `customer_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '客户名称', + `customer_contact` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '客户联系方式', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '关联项目ID', + `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联项目名称', + `contract_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '合同编号', + `total_amount` decimal(15, 2) NOT NULL COMMENT '总金额', + `received_amount` decimal(15, 2) NULL COMMENT '已收金额', + `pending_amount` decimal(15, 2) NULL COMMENT '待收金额', + `account_id` bigint(0) NULL DEFAULT NULL COMMENT '收款账户ID', + `account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款账户名称', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态 pending/partial/received/overdue', + `expected_date` date NULL DEFAULT NULL COMMENT '预计收款日期', + `actual_date` date NULL DEFAULT NULL COMMENT '实际收款日期', + `invoice_required` tinyint(0) NULL DEFAULT 0 COMMENT '是否需要发票', + `invoice_issued` tinyint(0) NULL DEFAULT 0 COMMENT '发票是否已开', + `invoice_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票号', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_by_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人名称', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_income_no`(`income_no`) USING BTREE, + INDEX `idx_type`(`type`) USING BTREE, + INDEX `idx_customer_id`(`customer_id`) USING BTREE, + INDEX `idx_project_id`(`project_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE, + INDEX `idx_expected_date`(`expected_date`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务收入表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_income +-- ---------------------------- + +-- ---------------------------- +-- Table structure for finance_invoice +-- ---------------------------- +DROP TABLE IF EXISTS `finance_invoice`; +CREATE TABLE `finance_invoice` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `settlement_id` bigint(0) NULL DEFAULT NULL COMMENT '关联结算单ID', + `type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'vat_normal' COMMENT '发票类型 vat_special/vat_normal/personal', + `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发票抬头', + `tax_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '税号', + `amount` decimal(15, 2) NOT NULL COMMENT '金额', + `file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '电子发票文件URL', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态 pending/issued/rejected', + `submit_time` date NULL DEFAULT NULL COMMENT '提交时间', + `issue_time` date NULL DEFAULT NULL COMMENT '开票时间', + `reject_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '驳回原因', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_settlement_id`(`settlement_id`) USING BTREE, + INDEX `idx_type`(`type`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务发票表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_invoice +-- ---------------------------- + +-- ---------------------------- +-- Table structure for finance_reimbursement +-- ---------------------------- +DROP TABLE IF EXISTS `finance_reimbursement`; +CREATE TABLE `finance_reimbursement` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `reimbursement_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报销单号', + `type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'other' COMMENT '报销类型 travel/meal/transport/communication/office/other', + `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报销标题', + `applicant_id` bigint(0) NULL DEFAULT NULL COMMENT '申请人ID', + `applicant_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '申请人名称', + `department_id` bigint(0) NULL DEFAULT NULL COMMENT '部门ID', + `department_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称', + `total_amount` decimal(15, 2) NOT NULL COMMENT '总金额', + `items` json NULL COMMENT '报销明细', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` bigint(0) NULL DEFAULT NULL COMMENT '审批流程ID', + `approval_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批状态', + `current_approver` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当前审批人', + `bank_account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款人银行户名', + `bank_account_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款人银行账号', + `bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收款人开户行', + `payment_account_id` bigint(0) NULL DEFAULT NULL COMMENT '付款账户ID', + `payment_date` date NULL DEFAULT NULL COMMENT '付款日期', + `payment_remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '付款备注', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_reimbursement_no`(`reimbursement_no`) USING BTREE, + INDEX `idx_type`(`type`) USING BTREE, + INDEX `idx_applicant_id`(`applicant_id`) USING BTREE, + INDEX `idx_department_id`(`department_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务报销表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_reimbursement +-- ---------------------------- + +-- ---------------------------- +-- Table structure for finance_settlement +-- ---------------------------- +DROP TABLE IF EXISTS `finance_settlement`; +CREATE TABLE `finance_settlement` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '项目ID', + `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '项目名称', + `talent_id` bigint(0) NULL DEFAULT NULL COMMENT '人才ID', + `talent_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '人才名称', + `period` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '结算周期/月份', + `total_amount` decimal(15, 2) NULL COMMENT '项目总额', + `platform_fee` decimal(15, 2) NULL COMMENT '平台服务费', + `taxable_amount` decimal(15, 2) NULL COMMENT '应纳税所得额', + `tax_rate` decimal(5, 4) NULL COMMENT '税率', + `tax_amount` decimal(15, 2) NULL COMMENT '扣税金额', + `actual_amount` decimal(15, 2) NULL COMMENT '实发金额', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态 pending/paying/completed/rejected', + `invoice_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'none' COMMENT '发票状态 none/pending/received', + `bank_account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '银行户名', + `bank_account_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '银行账号', + `bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '开户银行', + `audit_time` date NULL DEFAULT NULL COMMENT '审核时间', + `payment_time` date NULL DEFAULT NULL COMMENT '打款时间', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_project_id`(`project_id`) USING BTREE, + INDEX `idx_talent_id`(`talent_id`) USING BTREE, + INDEX `idx_period`(`period`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '财务结算表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of finance_settlement +-- ---------------------------- + +-- ---------------------------- +-- Table structure for platform_certificate +-- ---------------------------- +DROP TABLE IF EXISTS `platform_certificate`; +CREATE TABLE `platform_certificate` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `server_id` bigint(0) NOT NULL COMMENT '关联服务器ID', + `server_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '服务器名称', + `panel_ssl_id` bigint(0) NULL DEFAULT NULL COMMENT '1Panel证书ID', + `primary_domain` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主域名', + `other_domains` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '其他域名(逗号分隔)', + `cn` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '证书主体名称', + `organization` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '颁发组织', + `provider` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'dnsAccount' COMMENT '验证方式 dnsAccount/httpManual', + `acme_account_id` bigint(0) NULL DEFAULT NULL COMMENT 'Acme账户ID', + `acme_account_email` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'Acme账户邮箱', + `dns_account_id` bigint(0) NULL DEFAULT NULL COMMENT 'DNS账户ID', + `dns_account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'DNS账户名称', + `dns_account_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'DNS账户类型', + `key_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'P256' COMMENT '密钥算法 P256/P384/RSA2048/RSA4096', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态 pending/valid/expired/error', + `auto_renew` tinyint(1) NULL DEFAULT 1 COMMENT '自动续签', + `start_date` date NULL DEFAULT NULL COMMENT '生效时间', + `expire_date` date NULL DEFAULT NULL COMMENT '过期时间', + `cert_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '证书内容', + `key_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '私钥内容', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `last_sync_time` datetime(0) NULL DEFAULT NULL COMMENT '最后同步时间', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_server_id`(`server_id`) USING BTREE, + INDEX `idx_panel_ssl_id`(`panel_ssl_id`) USING BTREE, + INDEX `idx_primary_domain`(`primary_domain`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE, + INDEX `idx_expire_date`(`expire_date`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 35 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '平台证书表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of platform_certificate +-- ---------------------------- +INSERT INTO `platform_certificate` VALUES (1, 1, '生产服务器', 8, 'vnc.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-11-14', '2026-02-12', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:07', '2026-01-13 15:46:07', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (2, 1, '生产服务器', 7, 'manage.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-11-12', '2026-02-10', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:07', '2026-01-13 15:46:07', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (3, 1, '生产服务器', 5, 'test.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-30', '2026-03-30', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:07', '2026-01-13 15:46:07', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (4, 1, '生产服务器', 4, 'rustfs.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-15', '2026-03-15', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:07', '2026-01-13 15:46:07', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (5, 1, '生产服务器', 3, 'www.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:07', '2026-01-13 15:46:07', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (6, 1, '生产服务器', 2, 'apis.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-13 17:46:37', '2026-01-13 15:46:08', '2026-01-13 15:46:08', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (7, 1, '生产服务器', 10, 'testes.superwax.cn', '', '', '', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '0001-01-01', '2026-01-13', NULL, NULL, '', '2026-01-13 17:27:58', '2026-01-13 17:27:21', '2026-01-13 17:28:48', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (8, 1, '生产服务器', 9, 'testes.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-13 17:27:58', '2026-01-13 17:27:58', '2026-01-13 17:29:01', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (9, 1, '生产服务器', 11, 'testes.superwax.cn', '', NULL, '', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '0001-01-01', '2026-01-13', NULL, NULL, NULL, '2026-01-13 17:29:28', '2026-01-13 17:29:23', '2026-01-13 17:31:29', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (10, 1, '生产服务器', 12, 'testes.superwax.cn', '', NULL, '', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '0001-01-01', '2026-01-13', NULL, NULL, NULL, '2026-01-13 17:42:04', '2026-01-13 17:41:41', '2026-01-13 17:44:14', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (11, 1, '生产服务器', 13, 'testes.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-13 17:46:36', '2026-01-13 17:46:09', '2026-01-13 17:53:42', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (12, 1, '生产服务器', 14, 'testes.superwax.cn', '', NULL, 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, NULL, '2026-01-13 18:25:58', '2026-01-13 18:25:52', '2026-01-13 18:26:51', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (13, 1, '生产服务器', 15, 'testes.superwax.cn', '', NULL, 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, NULL, '2026-01-13 18:28:24', '2026-01-13 18:28:19', '2026-01-13 18:28:19', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (14, 4, '公司主服务器', 5, 'key.nanxiislet.com', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-18', '2026-03-18', NULL, NULL, '', '2026-01-15 13:23:57', '2026-01-13 18:57:53', '2026-01-13 18:57:53', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (15, 4, '公司主服务器', 4, 'www.nanxiislet.com', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-01', '2026-03-01', NULL, NULL, '', '2026-01-15 13:23:57', '2026-01-13 18:57:53', '2026-01-13 18:57:53', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (16, 4, '公司主服务器', 3, 'gitea.nanxiislet.com', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-11-30', '2026-02-28', NULL, NULL, '', '2026-01-15 13:23:57', '2026-01-13 18:57:53', '2026-01-13 18:57:53', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (17, 4, '公司主服务器', 2, 'nanxiislet.com', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-11-30', '2026-02-28', NULL, NULL, '', '2026-01-15 13:23:57', '2026-01-13 18:57:53', '2026-01-13 18:57:53', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (18, 6, '生成服务器', 15, 'testes.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-15 17:49:34', '2026-01-15 17:46:36', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (19, 6, '生成服务器', 8, 'vnc.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-11-14', '2026-02-12', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:36', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (20, 6, '生成服务器', 7, 'manage.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:36', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (21, 6, '生成服务器', 5, 'test.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-30', '2026-03-30', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:36', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (22, 6, '生成服务器', 4, 'rustfs.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-15', '2026-03-15', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:37', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (23, 6, '生成服务器', 3, 'www.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:37', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (24, 6, '生成服务器', 2, 'apis.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-15 17:49:35', '2026-01-15 17:46:37', '2026-01-15 17:49:49', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (25, 4, '公司主服务器', 6, 'test.nanxiislet.com', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-15', '2026-04-15', NULL, NULL, '', '2026-01-15 13:23:57', '2026-01-15 17:50:32', '2026-01-15 17:50:32', 1, 1, 0); +INSERT INTO `platform_certificate` VALUES (26, 7, '私人服务器', 15, 'testes.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-19 16:38:01', '2026-01-19 16:38:01', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (27, 7, '私人服务器', 8, 'vnc.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-15', '2026-04-15', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (28, 7, '私人服务器', 7, 'manage.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2026-01-13', '2026-04-13', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (29, 7, '私人服务器', 5, 'test.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-30', '2026-03-30', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (30, 7, '私人服务器', 4, 'rustfs.superwax.cn', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-15', '2026-03-15', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (31, 7, '私人服务器', 3, 'www.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (32, 7, '私人服务器', 2, 'apis.superwax.cn', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'P256', 'valid', 1, '2025-12-12', '2026-03-12', NULL, NULL, '', '2026-01-19 16:38:02', '2026-01-19 16:38:02', '2026-01-19 20:08:23', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (33, 8, '后端主服务器', 2, 'apis.nanxiislet.com', '', NULL, '', 'dnsAccount', 1, NULL, 1, NULL, NULL, 'EC 256', 'valid', 1, '0001-01-01', '2026-01-21', NULL, NULL, NULL, '2026-01-21 15:48:54', '2026-01-21 15:48:48', '2026-01-21 15:55:01', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (34, 8, '后端主服务器', 3, 'apis.nanxiislet.com', '', 'E7', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 2, NULL, NULL, 'P256', 'valid', 1, '2026-01-21', '2026-04-21', NULL, NULL, '', '2026-01-21 15:56:25', '2026-01-21 15:55:21', '2026-01-21 17:37:07', 1, 1, 1); +INSERT INTO `platform_certificate` VALUES (35, 8, '后端主服务器', 4, 'apis.codeport.online', '', 'E8', 'Let\'s Encrypt', 'dnsAccount', 1, NULL, 2, NULL, NULL, 'P256', 'valid', 1, '2026-01-21', '2026-04-21', NULL, NULL, '', '2026-01-21 17:39:18', '2026-01-21 17:37:45', '2026-01-21 17:37:45', 1, 1, 0); + +-- ---------------------------- +-- Table structure for platform_domain +-- ---------------------------- +DROP TABLE IF EXISTS `platform_domain`; +CREATE TABLE `platform_domain` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '域名', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '关联项目ID', + `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联项目名称', + `server_id` bigint(0) NULL DEFAULT NULL COMMENT '关联服务器ID', + `server_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联服务器名称', + `server_ip` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '服务器IP', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态 active/pending/expired/error', + `dns_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'checking' COMMENT 'DNS状态 resolved/unresolved/checking', + `dns_records` json NULL COMMENT 'DNS记录', + `ssl_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'none' COMMENT 'SSL状态 valid/expiring/expired/none', + `ssl_expire_date` date NULL DEFAULT NULL COMMENT 'SSL过期时间', + `certificate_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '证书ID', + `certificate_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '证书名称', + `nginx_config_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'Nginx配置路径', + `proxy_pass` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '代理地址', + `port` int(0) NULL DEFAULT 80 COMMENT '端口', + `enable_https` tinyint(0) NULL DEFAULT 0 COMMENT '是否启用HTTPS', + `force_https` tinyint(0) NULL DEFAULT 0 COMMENT '是否强制HTTPS', + `panel_website_id` bigint(0) NULL DEFAULT NULL COMMENT '1Panel网站ID', + `panel_ssl_id` bigint(0) NULL DEFAULT NULL COMMENT '1Panel证书ID', + `site_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '网站目录路径', + `alias` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '网站别名', + `deploy_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'not_deployed' COMMENT '部署状态 not_deployed/deploying/deployed/failed', + `last_deploy_time` datetime(0) NULL DEFAULT NULL COMMENT '最后部署时间', + `last_deploy_message` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最后部署消息', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + `runtime_id` bigint(0) NULL DEFAULT NULL COMMENT '关联运行环境ID', + `runtime_server_id` bigint(0) NULL DEFAULT NULL COMMENT '运行环境所属服务器ID', + `runtime_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '运行环境名称', + `runtime_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '运行环境类型 java/node', + `runtime_deploy_status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '运行环境部署状态 not_deployed/deploying/deployed/failed', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_domain`(`domain`) USING BTREE, + INDEX `idx_project_id`(`project_id`) USING BTREE, + INDEX `idx_server_id`(`server_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE, + INDEX `idx_deploy_status`(`deploy_status`) USING BTREE, + INDEX `idx_panel_website_id`(`panel_website_id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 17 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '平台域名表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of platform_domain +-- ---------------------------- +INSERT INTO `platform_domain` VALUES (1, 'vnc.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-04-15', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: vnc.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (2, 'manage.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-04-13', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: manage.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (3, 'test.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-03-30', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: test.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (4, 'rustfs.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-03-15', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: rustfs.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (5, 'www.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-03-12', '31', NULL, NULL, '', 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: www.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (6, 'apis.superwax.cn', NULL, NULL, 7, '私人服务器', '47.109.57.58', 'active', 'resolved', NULL, 'valid', '2026-03-12', '32', NULL, NULL, 'https://127.0.0.1:3000', 80, 1, 0, 42, NULL, NULL, NULL, 'undeployed', '2026-01-19 19:31:19', '已删除部署', '从证书同步: apis.superwax.cn', '2026-01-13 16:41:19', '2026-01-19 20:08:23', 1, 1, 1, 7, 7, 'nodeAPP', NULL, NULL); +INSERT INTO `platform_domain` VALUES (7, 'testes.superwax.cn', 7, '框架', 7, '私人服务器', '47.109.57.58', 'pending', 'resolved', NULL, 'valid', '2026-04-13', '13', NULL, NULL, '', 80, 1, 0, 29, 15, '/opt/1panel/www/sites/testes.superwax.cn/index', 'testes_superwax_cn', 'pending', '2026-01-13 22:18:08', '部署成功', '', '2026-01-13 18:28:42', '2026-01-19 20:08:23', 1, 1, 1, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (8, 'key.nanxiislet.com', 13, 'codePort管理', 4, '公司主服务器', '47.92.161.35', 'active', 'resolved', NULL, 'valid', '2026-03-18', NULL, NULL, NULL, '', 443, 1, 0, 13, 5, NULL, NULL, 'deployed', '2026-01-21 20:50:52', '部署成功', '从证书同步: key.nanxiislet.com', '2026-01-13 18:58:06', '2026-01-15 21:15:29', 1, 1, 0, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (9, 'www.nanxiislet.com', NULL, NULL, 4, '公司主服务器', '47.92.161.35:34885', 'active', 'resolved', NULL, 'valid', '2026-03-01', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: www.nanxiislet.com', '2026-01-13 18:58:07', '2026-01-13 18:58:07', 1, 1, 0, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (10, 'gitea.nanxiislet.com', NULL, NULL, 4, '公司主服务器', '47.92.161.35:34885', 'active', 'resolved', NULL, 'valid', '2026-02-28', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: gitea.nanxiislet.com', '2026-01-13 18:58:07', '2026-01-13 18:58:07', 1, 1, 0, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (11, 'nanxiislet.com', NULL, NULL, 4, '公司主服务器', '47.92.161.35:34885', 'active', 'resolved', NULL, 'valid', '2026-02-28', NULL, NULL, NULL, NULL, 443, 1, 0, NULL, NULL, NULL, NULL, 'not_deployed', NULL, NULL, '从证书同步: nanxiislet.com', '2026-01-13 18:58:07', '2026-01-13 18:58:07', 1, 1, 0, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (12, 'test.nanxiislet.com', 9, '楠溪框架', 4, '公司主服务器', '47.92.161.35', 'active', 'resolved', NULL, 'valid', '2026-04-15', '25', NULL, NULL, '', 443, 1, 0, 11, 6, NULL, NULL, 'deployed', '2026-01-15 17:59:43', '部署成功', '从证书同步: test.nanxiislet.com', '2026-01-15 17:52:41', '2026-01-15 17:59:27', 1, 1, 0, NULL, NULL, NULL, NULL, NULL); +INSERT INTO `platform_domain` VALUES (16, 'apis.nanxiislet.com', NULL, NULL, 8, '后端主服务器', '115.159.225.39', 'active', 'resolved', NULL, 'valid', '2026-04-21', '34', NULL, NULL, 'https://115.159.225.39:8080', 80, 1, 0, 3, NULL, '/opt/1panel/www/sites/apis.nanxiislet.com/index', 'apis_nanxiislet_com', 'undeployed', '2026-01-21 16:15:24', '已删除部署', '后端服务', '2026-01-21 15:57:20', '2026-01-21 17:37:00', 1, 1, 1, 1, 8, 'nanxiJAVA', NULL, NULL); +INSERT INTO `platform_domain` VALUES (17, 'apis.codeport.online', NULL, NULL, 8, '后端主服务器', '115.159.225.39', 'active', 'resolved', NULL, 'valid', '2026-04-21', '35', NULL, NULL, '', 80, 1, 0, 14, NULL, NULL, NULL, 'deployed', '2026-01-21 20:43:47', '运行环境部署成功', '从证书同步: apis.codeport.online', '2026-01-21 17:39:25', '2026-01-21 20:43:44', 1, 1, 0, 2, 8, 'nanxiJAVA', 'java', 'deployed'); + +-- ---------------------------- +-- Table structure for platform_project +-- ---------------------------- +DROP TABLE IF EXISTS `platform_project`; +CREATE TABLE `platform_project` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目名称', + `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '项目编码', + `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '项目类型', + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '访问地址', + `domain_id` bigint(0) NULL DEFAULT NULL COMMENT '关联域名ID', + `server_id` bigint(0) NULL DEFAULT NULL COMMENT '关联服务器ID', + `server_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '服务器名称', + `deploy_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部署路径', + `version` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当前版本', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'inactive' COMMENT '状态 active/inactive/deploying', + `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '图标', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述', + `sort` int(0) NULL DEFAULT 0 COMMENT '排序', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + `short_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '项目简称', + `project_group` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'default' COMMENT '项目分组', + `logo` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'Logo文字', + `color` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '主题色', + `domain` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '绑定域名', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `enable_https` tinyint(1) NULL DEFAULT 0 COMMENT '是否启用HTTPS', + `panel_website_id` bigint(0) NULL DEFAULT NULL COMMENT '1Panel网站ID', + `panel_ssl_id` bigint(0) NULL DEFAULT NULL COMMENT '1Panel证书ID', + `last_deploy_time` datetime(0) NULL DEFAULT NULL COMMENT '最后部署时间', + `last_deploy_status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最后部署状态', + `last_deploy_message` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最后部署消息', + `system_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'admin' COMMENT '系统类型 admin/portal', + `integrate_to_framework` tinyint(1) NULL DEFAULT 0 COMMENT '是否集成到框架', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_name`(`name`) USING BTREE, + INDEX `idx_code`(`code`) USING BTREE, + INDEX `idx_server_id`(`server_id`) USING BTREE, + INDEX `idx_domain_id`(`domain_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '平台项目表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of platform_project +-- ---------------------------- +INSERT INTO `platform_project` VALUES (1, '南溪屿后台管理', 'nanxiislet-admin', 'frontend', NULL, NULL, NULL, NULL, NULL, '1.0.0', 'active', NULL, '南溪屿后台管理系统前端', 1, '2026-01-10 18:37:46', '2026-01-13 19:11:54', NULL, 1, 1, NULL, 'default', NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, NULL, NULL, 'admin', 0); +INSERT INTO `platform_project` VALUES (2, '南溪屿API服务', 'nanxiislet-api', 'backend', NULL, NULL, NULL, NULL, NULL, '1.0.0', 'active', NULL, '南溪屿后台管理系统API服务', 2, '2026-01-10 18:37:46', '2026-01-13 19:11:51', NULL, 1, 1, NULL, 'default', NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, NULL, NULL, 'admin', 0); +INSERT INTO `platform_project` VALUES (3, '阿里', 'yunai', 'web', 'https://testes.superwax.cn', 7, 1, NULL, '/opt/1panel/www/sites/testes.superwax.cn/index', NULL, 'active', NULL, '测试项目', 0, '2026-01-13 19:12:39', '2026-01-13 21:20:04', 1, 1, 1, 'codes', 'default', '代', '#1890ff', 'testes.superwax.cn', '测试项目', 1, 22, 15, '2026-01-13 20:40:35', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (4, '阿里', 'yunai', 'web', 'https://testes.superwax.cn', 7, 1, NULL, '/opt/1panel/www/sites/testes.superwax.cn/index', NULL, 'active', NULL, '测试项目', 0, '2026-01-13 21:20:36', '2026-01-13 21:28:19', 1, 1, 1, 'codes', 'default', '代', '#1890ff', 'testes.superwax.cn', '', 1, 23, 15, '2026-01-13 21:20:39', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (5, '阿里', 'yunai', 'web', 'https://testes.superwax.cn', 7, 1, NULL, '/opt/1panel/www/sites/testes.superwax.cn/index', NULL, 'active', NULL, '测试项目', 0, '2026-01-13 21:28:48', '2026-01-13 21:34:45', 1, 1, 1, 'codes', 'default', '代', '#1890ff', 'testes.superwax.cn', '', 1, 25, 15, '2026-01-13 21:34:23', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (6, '阿里', 'yunai', 'web', 'https://testes.superwax.cn', 7, 1, NULL, '/opt/1panel/www/sites/testes.superwax.cn/index', NULL, 'active', NULL, '测试项目', 0, '2026-01-13 21:36:04', '2026-01-13 22:17:30', 1, 1, 1, 'codes', 'default', '代', '#1890ff', 'testes.superwax.cn', '测试项目', 1, 28, 15, '2026-01-13 22:17:16', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (7, '框架', 'yunai', 'web', 'http://testes.superwax.cn', 7, 1, NULL, '/opt/1panel/www/sites/testes.superwax.cn/index', NULL, 'active', NULL, '测试项目', 0, '2026-01-13 22:18:02', '2026-01-15 17:39:44', 1, 1, 1, 'codes', 'default', '代', '#1890ff', 'testes.superwax.cn', '测试项目', 1, 29, 15, '2026-01-13 22:18:07', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (8, '楠溪框架', 'nanxiAdmin', 'web', 'https://test.nanxiislet.com', 12, 4, NULL, '/opt/1panel/www/sites/test.nanxiislet.com/index', NULL, 'active', NULL, '框架', 0, '2026-01-15 17:52:29', '2026-01-15 17:55:25', 1, 1, 1, 'NanxiAdmin', 'default', '框', '#1890ff', 'test.nanxiislet.com', '', 1, 10, 6, '2026-01-15 17:53:06', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (9, '楠溪框架', 'nanxiAdmin', 'web', 'https://test.nanxiislet.com', 12, 4, NULL, '/opt/1panel/www/sites/test.nanxiislet.com/index', NULL, 'active', NULL, '公司整体框架', 0, '2026-01-15 17:58:04', '2026-01-15 21:33:47', 1, 1, 0, 'NanxiAdmin', 'default', '框', '#1890ff', 'test.nanxiislet.com', '公司整体框架', 1, 11, 6, '2026-01-15 17:59:41', 'success', '部署成功', 'admin', 0); +INSERT INTO `platform_project` VALUES (10, '阿里', 'yunai', 'web', '', NULL, 4, NULL, '', NULL, 'active', NULL, '', 0, '2026-01-15 18:24:33', '2026-01-15 18:26:49', 1, 1, 1, 'codes', 'default', '代', '#1890ff', NULL, '', 0, NULL, NULL, NULL, NULL, NULL, 'admin', 1); +INSERT INTO `platform_project` VALUES (11, '树莓派', 'yunai', 'web', '', NULL, 4, NULL, '', NULL, 'active', NULL, '', 0, '2026-01-15 18:26:09', '2026-01-15 18:26:54', 1, 1, 1, 'ghfs', 'default', '与', '#1890ff', '', '', 0, NULL, NULL, NULL, NULL, NULL, 'admin', 1); +INSERT INTO `platform_project` VALUES (12, '阿里', 'yunai', 'web', '', NULL, 4, NULL, '', NULL, 'active', NULL, '', 0, '2026-01-15 19:46:40', '2026-01-15 19:49:24', 1, 1, 1, 'codes', 'default', '框', '#1890ff', NULL, '', 0, NULL, NULL, NULL, NULL, NULL, 'admin', 1); +INSERT INTO `platform_project` VALUES (13, 'codePort管理', 'yunai', 'web', 'https://key.nanxiislet.com', 8, 4, NULL, '/opt/1panel/www/sites/key.nanxiislet.com/index', NULL, 'active', NULL, 'codePort后台管理', 0, '2026-01-15 19:56:37', '2026-01-15 21:34:03', 1, 1, 0, 'codes', 'default', '管', '#1890ff', 'key.nanxiislet.com', 'codePort后台管理', 1, 13, 5, '2026-01-21 20:50:49', 'success', '部署成功', 'admin', 1); + +-- ---------------------------- +-- Table structure for platform_server +-- ---------------------------- +DROP TABLE IF EXISTS `platform_server`; +CREATE TABLE `platform_server` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '服务器名称', + `ip` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '公网IP', + `internal_ip` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '内网IP', + `port` int(0) NULL DEFAULT 22 COMMENT 'SSH端口', + `type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'cloud' COMMENT '服务器类型 physical/virtual/cloud', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'offline' COMMENT '状态 online/offline/warning/maintenance', + `os` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '操作系统', + `cpu_cores` int(0) NULL DEFAULT NULL COMMENT 'CPU核心数', + `memory_total` int(0) NULL DEFAULT NULL COMMENT '内存大小(GB)', + `disk_total` int(0) NULL DEFAULT NULL COMMENT '磁盘大小(GB)', + `tags` json NULL COMMENT '标签', + `panel_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '1Panel面板地址', + `panel_port` int(0) NULL DEFAULT NULL COMMENT '1Panel面板端口', + `panel_api_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '1Panel API密钥', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_name`(`name`) USING BTREE, + INDEX `idx_ip`(`ip`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 9 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '平台服务器表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of platform_server +-- ---------------------------- +INSERT INTO `platform_server` VALUES (1, '生产服务器', '47.109.57.58', '192.168.1.10', 22, 'cloud', 'online', 'CentOS 9', 2, 1, 39, '[\"生产\"]', 'http://47.109.57.58:42588/super', 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', '主生产服务器', '2026-01-10 18:37:46', '2026-01-15 17:39:49', NULL, 1, 1); +INSERT INTO `platform_server` VALUES (2, '测试服务器', '47.98.123.46', '192.168.1.11', 22, 'cloud', 'online', 'Ubuntu 22.04', 2, 4, 50, NULL, NULL, NULL, NULL, '测试环境服务器', '2026-01-10 18:37:46', '2026-01-10 20:23:37', NULL, 1, 1); +INSERT INTO `platform_server` VALUES (3, '阿里', '47.108.5.60', NULL, 22, 'cloud', 'offline', 'centos9.0', NULL, NULL, NULL, NULL, NULL, 8888, NULL, NULL, '2026-01-13 17:04:57', '2026-01-13 18:57:10', 1, 1, 1); +INSERT INTO `platform_server` VALUES (4, '公司主服务器', '47.92.161.35', NULL, 22, 'cloud', 'online', 'centos9.0', 2, 1, 39, '[\"生产\", \"开发\"]', 'http://47.92.161.35:34885/super', 34885, 'rq4YoqLLVEz37GB2XSYOMjBfF7CqtYtZ', NULL, '2026-01-13 18:57:01', '2026-01-21 21:01:02', 1, 1, 0); +INSERT INTO `platform_server` VALUES (5, '树莓派', 'panel.superwax.cn', '192.168.9.100', 22, 'cloud', 'offline', '树莓派系统', NULL, NULL, NULL, '[\"生产\", \"测试\"]', 'http://panel.superwax.cn:8090/super', 8090, 'fjTRfXmFEuPqN8cd25WuOo9ml8W2g0fP', '家里服务器', '2026-01-15 10:34:34', '2026-01-15 15:02:08', 1, 1, 1); +INSERT INTO `platform_server` VALUES (6, '生成服务器', '47.109.57.58', NULL, 22, 'cloud', 'online', 'centos9.0', 2, 1, 39, NULL, 'http://47.109.57.58:42588/super', 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', NULL, '2026-01-15 17:46:19', '2026-01-15 17:49:50', 1, 1, 1); +INSERT INTO `platform_server` VALUES (7, '私人服务器', '47.109.57.58', NULL, 22, 'cloud', 'online', 'centos9.0', 2, 1, 39, NULL, 'http://47.109.57.58:42588/super', 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', NULL, '2026-01-17 19:42:04', '2026-01-19 12:08:24', 1, 1, 1); +INSERT INTO `platform_server` VALUES (8, '后端主服务器', '115.159.225.39', NULL, 22, 'cloud', 'online', 'Ubuntu 22.04.4 LTS', 4, 7, 117, '[\"生产\", \"开发\", \"测试\"]', 'http://115.159.225.39:8090/tencentcloud', 8090, 'MBcF6RHwUQC3fvSWxSwKQcd7zpMdYpKC', '部署后端主服务', '2026-01-21 04:59:57', '2026-01-21 21:01:02', 1, 1, 0); + +-- ---------------------------- +-- Table structure for sys_approval_instance +-- ---------------------------- +DROP TABLE IF EXISTS `sys_approval_instance`; +CREATE TABLE `sys_approval_instance` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` bigint(0) NOT NULL COMMENT '模板ID', + `template_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '模板名称', + `scenario` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '适用场景', + `business_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型', + `business_id` bigint(0) NOT NULL COMMENT '业务ID', + `business_title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '业务标题', + `initiator_id` bigint(0) NOT NULL COMMENT '发起人ID', + `initiator_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发起人名称', + `initiator_avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发起人头像', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'pending' COMMENT '状态', + `current_node_id` bigint(0) NULL DEFAULT NULL COMMENT '当前节点ID', + `current_node_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当前节点名称', + `submitted_at` datetime(0) NULL DEFAULT NULL COMMENT '提交时间', + `completed_at` datetime(0) NULL DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_template_id`(`template_id`) USING BTREE, + INDEX `idx_business`(`business_type`, `business_id`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '审批实例表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_approval_instance +-- ---------------------------- + +-- ---------------------------- +-- Table structure for sys_approval_node +-- ---------------------------- +DROP TABLE IF EXISTS `sys_approval_node`; +CREATE TABLE `sys_approval_node` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` bigint(0) NOT NULL COMMENT '模板ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '节点名称', + `approver_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'specified' COMMENT '审批人类型: specified-指定人员 role-按角色 department-按部门 superior-上级领导 self_select-发起人自选', + `approver_ids` json NULL COMMENT '审批人ID列表', + `approver_role` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批角色编码', + `dept_id` bigint(0) NULL DEFAULT NULL COMMENT '部门ID', + `dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称', + `approval_mode` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'or' COMMENT '审批方式: and-会签 or-或签', + `timeout_hours` int(0) NULL DEFAULT NULL COMMENT '超时时间(小时)', + `timeout_action` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '超时操作', + `sort` int(0) NULL DEFAULT 0 COMMENT '节点顺序', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_template_id`(`template_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '审批流程节点表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_approval_node +-- ---------------------------- + +-- ---------------------------- +-- Table structure for sys_approval_record +-- ---------------------------- +DROP TABLE IF EXISTS `sys_approval_record`; +CREATE TABLE `sys_approval_record` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `instance_id` bigint(0) NOT NULL COMMENT '实例ID', + `node_id` bigint(0) NOT NULL COMMENT '节点ID', + `node_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '节点名称', + `approver_id` bigint(0) NOT NULL COMMENT '审批人ID', + `approver_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批人名称', + `approver_avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批人头像', + `action` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作', + `comment` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '审批意见', + `operated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_instance_id`(`instance_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '审批记录表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_approval_record +-- ---------------------------- + +-- ---------------------------- +-- Table structure for sys_approval_template +-- ---------------------------- +DROP TABLE IF EXISTS `sys_approval_template`; +CREATE TABLE `sys_approval_template` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板名称', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述', + `scenario` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '适用场景', + `enabled` tinyint(0) NULL DEFAULT 1 COMMENT '是否启用 0-禁用 1-启用', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_scenario`(`scenario`) USING BTREE, + INDEX `idx_enabled`(`enabled`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '审批流程模板表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_approval_template +-- ---------------------------- +INSERT INTO `sys_approval_template` VALUES (1, '费用报销审批', '员工费用报销审批流程', 'expense_reimbursement', 1, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_approval_template` VALUES (2, '付款申请审批', '付款申请审批流程', 'payment_request', 1, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_approval_template` VALUES (3, '项目发布审批', '项目发布前的审批流程', 'project_publish', 1, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); + +-- ---------------------------- +-- Table structure for sys_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dept`; +CREATE TABLE `sys_dept` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` bigint(0) NULL DEFAULT 0 COMMENT '父部门ID,0表示顶级部门', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '部门名称', + `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门编码', + `leader_id` bigint(0) NULL DEFAULT NULL COMMENT '部门负责人ID', + `leader_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门负责人名称', + `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '联系电话', + `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮箱', + `sort` int(0) NULL DEFAULT 0 COMMENT '排序', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_parent_id`(`parent_id`) USING BTREE, + INDEX `idx_code`(`code`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '部门管理表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_dept +-- ---------------------------- +INSERT INTO `sys_dept` VALUES (1, 0, '南溪屿科技', 'ROOT', NULL, NULL, NULL, NULL, 0, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (2, 1, '技术部', 'TECH', NULL, NULL, NULL, NULL, 1, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (3, 1, '产品部', 'PRODUCT', NULL, NULL, NULL, NULL, 2, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (4, 1, '财务部', 'FINANCE', NULL, NULL, NULL, NULL, 3, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (5, 1, '人力资源部', 'HR', NULL, NULL, NULL, NULL, 4, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (6, 2, '前端开发组', 'TECH-FE', NULL, NULL, NULL, NULL, 1, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (7, 2, '后端开发组', 'TECH-BE', NULL, NULL, NULL, NULL, 2, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); +INSERT INTO `sys_dept` VALUES (8, 2, '运维组', 'TECH-OPS', NULL, NULL, NULL, NULL, 3, 1, NULL, '2026-01-10 18:37:47', '2026-01-10 18:37:47', NULL, NULL, 0); + +-- ---------------------------- +-- Table structure for sys_dict +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict`; +CREATE TABLE `sys_dict` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '字典名称', + `code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '字典编码', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_code`(`code`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典类型表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_dict +-- ---------------------------- +INSERT INTO `sys_dict` VALUES (1, '性别', 'gender', '用户性别', 1, '2026-01-08 22:58:31', '2026-01-08 23:19:08', NULL, 1, 0); +INSERT INTO `sys_dict` VALUES (2, '状态', 'status', '通用状态', 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict` VALUES (3, '审批状态', 'approval_status', '审批流程状态', 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict` VALUES (4, '测试', 'test', '测试字典', 1, '2026-01-08 23:19:36', '2026-01-08 23:19:36', 1, 1, 0); +INSERT INTO `sys_dict` VALUES (5, '系统类别', 'system', '区别系统类型', 1, '2026-01-15 18:04:10', '2026-01-15 18:04:10', 1, 1, 0); +INSERT INTO `sys_dict` VALUES (6, '集成', 'integration', '是否集成管理系统到框架中', 1, '2026-01-15 18:06:26', '2026-01-15 18:06:26', 1, 1, 0); +INSERT INTO `sys_dict` VALUES (7, '运行环境', 'runSysteam', '系统运行环境', 1, '2026-01-21 19:09:55', '2026-01-21 19:09:55', 1, 1, 0); + +-- ---------------------------- +-- Table structure for sys_dict_item +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict_item`; +CREATE TABLE `sys_dict_item` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `dict_id` bigint(0) NOT NULL COMMENT '字典ID', + `label` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '字典项标签', + `value` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '字典项值', + `sort` int(0) NULL DEFAULT 0 COMMENT '排序', + `is_default` tinyint(0) NULL DEFAULT 0 COMMENT '是否默认 0-否 1-是', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_dict_value`(`dict_id`, `value`) USING BTREE, + INDEX `idx_dict_id`(`dict_id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典项表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_dict_item +-- ---------------------------- +INSERT INTO `sys_dict_item` VALUES (1, 1, '男', '1', 1, 1, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (2, 1, '女', '2', 2, 0, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (3, 1, '未知', '0', 3, 0, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (4, 2, '正常', '1', 1, 1, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (5, 2, '禁用', '0', 2, 0, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (6, 3, '待审批', 'pending', 1, 1, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (7, 3, '已通过', 'approved', 2, 0, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (8, 3, '已驳回', 'rejected', 3, 0, NULL, 1, '2026-01-08 22:58:31', '2026-01-08 22:58:31', NULL, NULL, 0); +INSERT INTO `sys_dict_item` VALUES (9, 4, '开启', 'open', 0, 1, '', 1, '2026-01-08 23:19:50', '2026-01-08 23:20:39', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (10, 4, '关闭', 'disable', 0, 0, '', 0, '2026-01-08 23:20:06', '2026-01-08 23:20:21', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (11, 5, '门户', 'portal', 0, 0, '', 1, '2026-01-15 18:04:27', '2026-01-15 18:04:27', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (12, 5, '管理端', 'admin', 1, 0, '', 1, '2026-01-15 18:04:38', '2026-01-15 18:04:38', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (13, 6, '集成', 'yes', 0, 0, '', 1, '2026-01-15 18:06:39', '2026-01-15 18:06:39', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (14, 6, '不集成', 'no', 0, 0, '', 1, '2026-01-15 18:06:55', '2026-01-15 18:06:55', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (15, 7, 'node', 'node', 0, 0, 'node运行环境', 1, '2026-01-21 19:10:21', '2026-01-21 19:10:21', 1, 1, 0); +INSERT INTO `sys_dict_item` VALUES (16, 7, 'java', 'java', 1, 0, 'java运行环境', 1, '2026-01-21 19:10:38', '2026-01-21 19:10:38', 1, 1, 0); + +-- ---------------------------- +-- Table structure for sys_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` bigint(0) NULL DEFAULT 0 COMMENT '父菜单ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单名称', + `code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单编码/Key', + `type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'menu' COMMENT '菜单类型 directory-目录 menu-菜单 button-按钮', + `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '路由路径', + `component` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '组件路径', + `icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '图标', + `permission` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '权限标识', + `sort` int(0) NULL DEFAULT 0 COMMENT '排序', + `hidden` tinyint(0) NULL DEFAULT 0 COMMENT '是否隐藏 0-显示 1-隐藏', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + `project_id` bigint(0) NULL DEFAULT NULL COMMENT '关联项目ID', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_code`(`code`) USING BTREE, + INDEX `idx_parent_id`(`parent_id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 67 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '菜单表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_menu +-- ---------------------------- +INSERT INTO `sys_menu` VALUES (1, 0, '财务管理', 'finance', 'directory', '/finance', NULL, 'PayCircleOutlined', NULL, 1, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (2, 1, '财务总览', 'finance-overview', 'menu', '/finance/overview', NULL, 'DashboardOutlined', NULL, 1, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (3, 1, '收入管理', 'income', 'menu', '/finance/income', NULL, 'RiseOutlined', NULL, 2, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (4, 1, '支出管理', 'expense', 'menu', '/finance/expense', NULL, 'FallOutlined', NULL, 3, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (5, 1, '报销管理', 'reimbursement', 'menu', '/finance/reimbursement', NULL, 'AuditOutlined', NULL, 4, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (6, 1, '结算管理', 'settlement', 'menu', '/finance/settlement', NULL, 'TransactionOutlined', NULL, 5, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (7, 1, '发票管理', 'invoice', 'menu', '/finance/invoice', NULL, 'FileTextOutlined', NULL, 6, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (8, 1, '账户管理', 'finance-accounts', 'menu', '/finance/accounts', NULL, 'BankOutlined', NULL, 7, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (9, 1, '预算管理', 'budget', 'menu', '/finance/budget', NULL, 'FundOutlined', NULL, 8, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (10, 1, '财务报表', 'finance-reports', 'menu', '/finance/reports', NULL, 'BarChartOutlined', NULL, 9, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (11, 0, '平台管理', 'platform', 'directory', '/platform', NULL, 'AppstoreOutlined', NULL, 2, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (12, 11, '项目管理', 'platform-projects', 'menu', '/platform/projects', NULL, 'ProjectOutlined', NULL, 1, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (13, 11, '服务器管理', 'platform-servers', 'menu', '/platform/servers', NULL, 'CloudServerOutlined', NULL, 2, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (14, 11, '域名管理', 'platform-domains', 'menu', '/platform/domains', NULL, 'GlobalOutlined', NULL, 3, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (15, 11, '证书管理', 'platform-certificates', 'menu', '/platform/certificates', NULL, 'SafetyCertificateOutlined', NULL, 4, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (16, 0, '系统管理', 'system', 'directory', '/system', NULL, 'SettingOutlined', NULL, 3, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (17, 16, '用户管理', 'system-users', 'menu', '/system/users', NULL, 'UserOutlined', NULL, 1, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (18, 16, '角色管理', 'system-roles', 'menu', '/system/roles', NULL, 'TeamOutlined', NULL, 2, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (19, 16, '菜单管理', 'system-menus', 'menu', '/system/menus', NULL, 'MenuOutlined', NULL, 4, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-09 20:42:19', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (20, 16, '字典管理', 'system-dict', 'menu', '/system/dict', NULL, 'BookOutlined', NULL, 5, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-09 20:42:19', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (21, 16, '审批流程', 'system-approval', 'menu', '/system/approval', NULL, 'ApartmentOutlined', NULL, 6, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-09 20:42:19', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (22, 16, '审批实例', 'system-approval-instances', 'menu', '/system/approval/instances', NULL, 'AuditOutlined', NULL, 7, 0, 1, NULL, '2026-01-08 20:54:12', '2026-01-09 20:42:19', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (23, 16, '部门管理', 'system-dept', 'menu', '/system/dept', NULL, 'ClusterOutlined', NULL, 3, 0, 1, NULL, '2026-01-09 20:42:19', '2026-01-09 20:42:19', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (28, 0, '阿里', 'yunai', 'menu', '/dashboard/wei', '', 'AlertOutlined', '', 0, 0, 1, '', '2026-01-15 18:46:45', '2026-01-15 18:46:56', 1, 1, 1, NULL); +INSERT INTO `sys_menu` VALUES (37, 0, '控制台', 'cp_dashboard', 'menu', '/dashboard', 'dashboard/index', 'DashboardOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (38, 0, '社区管理', 'cp_community', 'directory', '/community', NULL, 'TeamOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (39, 38, '帖子管理', 'cp_posts', 'menu', '/community/posts', 'community/posts/index', 'FileTextOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (40, 38, '评论管理', 'cp_comments', 'menu', '/community/comments', 'community/comments/index', 'MessageOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (41, 38, '标签管理', 'cp_tags', 'menu', '/community/tags', 'community/tags/index', 'TagsOutlined', NULL, 30, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (42, 38, '城市圈子', 'cp_circles', 'menu', '/community/circles', 'community/circles/index', 'EnvironmentOutlined', NULL, 40, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (43, 0, '客服管理', 'cp_support', 'directory', '/support', NULL, 'CustomerServiceOutlined', NULL, 30, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (44, 43, '接入会话', 'cp_support_console', 'menu', '/support/console', 'support/session/index', 'CommentOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (45, 43, '会话列表', 'cp_conversations', 'menu', '/support/conversations', 'support/conversations/index', 'UnorderedListOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (46, 0, '内容管理', 'cp_content', 'directory', '/content', NULL, 'ReadOutlined', NULL, 40, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (47, 46, '文章管理', 'cp_articles', 'menu', '/content/articles', 'content/articles/index', 'FileMarkdownOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (48, 0, '项目管理', 'cp_project', 'directory', '/project', NULL, 'ProjectOutlined', NULL, 50, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (49, 48, '项目列表', 'cp_projects', 'menu', '/project/list', 'project/list/index', 'UnorderedListOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (50, 48, '招募管理', 'cp_recruitment', 'menu', '/project/recruitment', 'project/recruitment/index', 'UserSwitchOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (51, 48, '已成交项目', 'cp_signed', 'menu', '/project/signed', 'project/signed/index', 'CheckCircleOutlined', NULL, 30, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (52, 48, '合同管理', 'cp_contract', 'menu', '/project/contract', 'project/contract/index', 'FileProtectOutlined', NULL, 40, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (53, 48, '会话管理', 'cp_proj_sessions', 'menu', '/project/sessions', 'project/session/index', 'CommentOutlined', NULL, 50, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (54, 48, '会话详情', 'cp_proj_sess_detail', 'menu', '/project/sessions/:id', 'project/session/detail', '', NULL, 60, 1, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (55, 0, '人才管理', 'cp_talent', 'directory', '/talent-mgr', NULL, 'UserOutlined', NULL, 60, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (56, 55, '人才列表', 'cp_talent_list', 'menu', '/talent', 'talent/index', 'UserOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (57, 55, '简历模板', 'cp_resume_tpl', 'menu', '/talent/resume-templates', 'talent/resume-templates', 'FileWordOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (58, 55, '人才详情', 'cp_talent_detail', 'menu', '/talent/:id', 'talent/detail', '', NULL, 30, 1, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (59, 0, '用户管理', 'cp_user', 'directory', '/user', NULL, 'UsergroupAddOutlined', NULL, 70, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (60, 59, '用户列表', 'cp_user_list', 'menu', '/user/list', 'user/list/index', 'UserOutlined', NULL, 10, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (61, 59, '角色管理', 'cp_roles', 'menu', '/user/roles', 'user/roles/index', 'SafetyCertificateOutlined', NULL, 20, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (62, 59, '认证管理', 'cp_certification', 'menu', '/user/certification', 'user/certification/index', 'IdcardOutlined', NULL, 30, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (63, 59, '岗位管理', 'cp_positions', 'menu', '/user/positions', 'user/positions/index', 'SolutionOutlined', NULL, 40, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (64, 59, '等级配置', 'cp_levels', 'menu', '/user/levels', 'user/levels/index', 'StarOutlined', NULL, 50, 0, 1, NULL, '2026-01-15 20:42:20', '2026-01-15 20:42:20', NULL, NULL, 0, 13); +INSERT INTO `sys_menu` VALUES (65, 11, '数据库管理', 'platform-databases', 'menu', '/platform/databases', 'platform/databases/index', 'DatabaseOutlined', NULL, 5, 0, 1, NULL, '2026-01-17 20:28:33', '2026-01-17 20:28:33', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (66, 11, '运行环境', 'platform-runtimes', 'menu', '/platform/runtimes', 'platform/runtimes/index', 'CodeOutlined', NULL, 6, 0, 1, NULL, '2026-01-18 22:09:47', '2026-01-18 22:09:47', NULL, NULL, 0, NULL); +INSERT INTO `sys_menu` VALUES (67, 11, '文件管理', 'PlatformFiles', 'menu', '/platform/files', 'platform/files/index', 'FileOutlined', NULL, 99, 0, 1, NULL, '2026-01-21 16:48:47', '2026-01-21 16:48:47', NULL, NULL, 0, 9); + +-- ---------------------------- +-- Table structure for sys_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色编码', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色名称', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '角色描述', + `sort` int(0) NULL DEFAULT 0 COMMENT '排序', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_code`(`code`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_role +-- ---------------------------- +INSERT INTO `sys_role` VALUES (1, 'super_admin', '超级管理员', '拥有系统所有权限', 1, 1, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0); +INSERT INTO `sys_role` VALUES (2, 'admin', '管理员', '系统管理员,可管理大部分功能', 2, 1, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0); +INSERT INTO `sys_role` VALUES (3, 'finance', '财务', '财务人员,只能访问财务相关功能', 3, 1, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0); +INSERT INTO `sys_role` VALUES (4, 'customer_service', '客服', '客服人员,只能访问客服相关功能', 4, 1, '2026-01-08 20:54:12', '2026-01-08 20:54:12', NULL, NULL, 0); + +-- ---------------------------- +-- Table structure for sys_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `role_id` bigint(0) NOT NULL COMMENT '角色ID', + `menu_id` bigint(0) NOT NULL COMMENT '菜单ID', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_role_menu`(`role_id`, `menu_id`) USING BTREE, + INDEX `idx_role_id`(`role_id`) USING BTREE, + INDEX `idx_menu_id`(`menu_id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 38 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色菜单关联表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_role_menu +-- ---------------------------- +INSERT INTO `sys_role_menu` VALUES (1, 1, 1); +INSERT INTO `sys_role_menu` VALUES (4, 1, 2); +INSERT INTO `sys_role_menu` VALUES (5, 1, 3); +INSERT INTO `sys_role_menu` VALUES (6, 1, 4); +INSERT INTO `sys_role_menu` VALUES (7, 1, 5); +INSERT INTO `sys_role_menu` VALUES (8, 1, 6); +INSERT INTO `sys_role_menu` VALUES (9, 1, 7); +INSERT INTO `sys_role_menu` VALUES (10, 1, 8); +INSERT INTO `sys_role_menu` VALUES (11, 1, 9); +INSERT INTO `sys_role_menu` VALUES (12, 1, 10); +INSERT INTO `sys_role_menu` VALUES (2, 1, 11); +INSERT INTO `sys_role_menu` VALUES (13, 1, 12); +INSERT INTO `sys_role_menu` VALUES (14, 1, 13); +INSERT INTO `sys_role_menu` VALUES (15, 1, 14); +INSERT INTO `sys_role_menu` VALUES (16, 1, 15); +INSERT INTO `sys_role_menu` VALUES (3, 1, 16); +INSERT INTO `sys_role_menu` VALUES (17, 1, 17); +INSERT INTO `sys_role_menu` VALUES (18, 1, 18); +INSERT INTO `sys_role_menu` VALUES (19, 1, 19); +INSERT INTO `sys_role_menu` VALUES (20, 1, 20); +INSERT INTO `sys_role_menu` VALUES (21, 1, 21); +INSERT INTO `sys_role_menu` VALUES (22, 1, 22); +INSERT INTO `sys_role_menu` VALUES (32, 1, 23); +INSERT INTO `sys_role_menu` VALUES (37, 1, 65); +INSERT INTO `sys_role_menu` VALUES (38, 1, 66); + +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名', + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码', + `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '昵称', + `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '头像', + `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '手机号', + `role` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'user' COMMENT '角色编码', + `dept_id` bigint(0) NULL DEFAULT NULL COMMENT '部门ID', + `dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称', + `status` tinyint(0) NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `last_login_time` datetime(0) NULL DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最后登录IP', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注', + `created_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间', + `updated_at` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', + `created_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人ID', + `updated_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人ID', + `deleted` tinyint(0) NULL DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_username`(`username`) USING BTREE, + INDEX `idx_phone`(`phone`) USING BTREE, + INDEX `idx_email`(`email`) USING BTREE, + INDEX `idx_status`(`status`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '系统用户表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_user +-- ---------------------------- +INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '超级管理员', NULL, NULL, NULL, 'super_admin', NULL, NULL, 1, '2026-01-21 19:03:34', NULL, '系统初始管理员', '2026-01-10 18:37:46', '2026-01-10 18:37:46', NULL, NULL, 0); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/main/resources/db/migration_project_deploy.sql b/src/main/resources/db/migration_project_deploy.sql new file mode 100644 index 0000000..01330eb --- /dev/null +++ b/src/main/resources/db/migration_project_deploy.sql @@ -0,0 +1,83 @@ +-- ============================================ +-- 项目部署功能 - 数据库迁移脚本 +-- 如果列已存在会报错,可以忽略重复列的错误继续执行 +-- ============================================ + +-- 创建一个存储过程来安全地添加列 +DROP PROCEDURE IF EXISTS add_column_if_not_exists; + +DELIMITER $$ + +CREATE PROCEDURE add_column_if_not_exists( + IN p_table VARCHAR(64), + IN p_column VARCHAR(64), + IN p_definition VARCHAR(255) +) +BEGIN + DECLARE column_exists INT DEFAULT 0; + + SELECT COUNT(*) INTO column_exists + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table + AND COLUMN_NAME = p_column; + + IF column_exists = 0 THEN + SET @sql = CONCAT('ALTER TABLE ', p_table, ' ADD COLUMN ', p_column, ' ', p_definition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + SELECT CONCAT('Added column: ', p_table, '.', p_column) AS Result; + ELSE + SELECT CONCAT('Column already exists: ', p_table, '.', p_column) AS Result; + END IF; +END$$ + +DELIMITER ; + +-- ============================================ +-- platform_project 表新增字段 +-- ============================================ + +CALL add_column_if_not_exists('platform_project', 'short_name', 'VARCHAR(50) COMMENT ''项目简称'''); +CALL add_column_if_not_exists('platform_project', 'project_group', 'VARCHAR(50) DEFAULT ''default'' COMMENT ''项目分组'''); +CALL add_column_if_not_exists('platform_project', 'logo', 'VARCHAR(50) COMMENT ''Logo文字'''); +CALL add_column_if_not_exists('platform_project', 'color', 'VARCHAR(50) COMMENT ''主题色'''); +CALL add_column_if_not_exists('platform_project', 'domain', 'VARCHAR(200) COMMENT ''绑定域名'''); +CALL add_column_if_not_exists('platform_project', 'remark', 'VARCHAR(500) COMMENT ''备注'''); +CALL add_column_if_not_exists('platform_project', 'enable_https', 'TINYINT(1) DEFAULT 0 COMMENT ''是否启用HTTPS'''); +CALL add_column_if_not_exists('platform_project', 'panel_website_id', 'BIGINT COMMENT ''1Panel网站ID'''); +CALL add_column_if_not_exists('platform_project', 'panel_ssl_id', 'BIGINT COMMENT ''1Panel证书ID'''); +CALL add_column_if_not_exists('platform_project', 'last_deploy_time', 'DATETIME COMMENT ''最后部署时间'''); +CALL add_column_if_not_exists('platform_project', 'last_deploy_status', 'VARCHAR(50) COMMENT ''最后部署状态'''); +CALL add_column_if_not_exists('platform_project', 'last_deploy_message', 'VARCHAR(500) COMMENT ''最后部署消息'''); + +-- ============================================ +-- platform_server 表新增字段 +-- ============================================ + +CALL add_column_if_not_exists('platform_server', 'panel_url', 'VARCHAR(200) COMMENT ''1Panel面板地址'''); +CALL add_column_if_not_exists('platform_server', 'panel_port', 'INT DEFAULT 42588 COMMENT ''1Panel面板端口'''); +CALL add_column_if_not_exists('platform_server', 'panel_api_key', 'VARCHAR(200) COMMENT ''1Panel API密钥'''); + +-- ============================================ +-- 更新服务器数据(更新1Panel配置) +-- ============================================ + +-- 如果服务器记录已存在,只更新 1Panel 配置 +UPDATE platform_server +SET panel_port = 42588, + panel_api_key = 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI' +WHERE id = 1; + +-- 如果没有更新任何行,则插入新记录 +INSERT INTO platform_server (id, name, ip, port, type, status, os, panel_port, panel_api_key) +SELECT 1, '生产服务器', '47.109.57.58', 22, 'cloud', 'online', 'CentOS 7', 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM platform_server WHERE id = 1); + +-- 清理存储过程 +DROP PROCEDURE IF EXISTS add_column_if_not_exists; + +-- 显示执行结果 +SELECT '数据库迁移完成!' AS '执行结果'; diff --git a/src/main/resources/db/remove_ssh_columns.sql b/src/main/resources/db/remove_ssh_columns.sql new file mode 100644 index 0000000..2700e8a --- /dev/null +++ b/src/main/resources/db/remove_ssh_columns.sql @@ -0,0 +1,3 @@ +-- 移除 platform_server 表中的 SSH 相关字段 +ALTER TABLE platform_server DROP COLUMN ssh_user; +ALTER TABLE platform_server DROP COLUMN ssh_port; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..9a822ad --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,554 @@ +-- ================================== +-- Nanxiislet Admin 数据库初始化脚本 +-- 创建日期: 2026-01-06 +-- ================================== + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS `nanxiislet` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE `nanxiislet`; + +-- ================================== +-- 系统用户表 +-- ================================== +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '密码', + `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `role` VARCHAR(50) DEFAULT 'user' COMMENT '角色编码', + `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `dept_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_phone` (`phone`), + KEY `idx_email` (`email`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统用户表'; + +-- ================================== +-- 平台服务器表 +-- ================================== +DROP TABLE IF EXISTS `platform_server`; +CREATE TABLE `platform_server` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '服务器名称', + `ip` VARCHAR(50) NOT NULL COMMENT '公网IP', + `internal_ip` VARCHAR(50) DEFAULT NULL COMMENT '内网IP', + `port` INT DEFAULT 22 COMMENT 'SSH端口', + `type` VARCHAR(20) DEFAULT 'cloud' COMMENT '服务器类型 physical/virtual/cloud', + `status` VARCHAR(20) DEFAULT 'offline' COMMENT '状态 online/offline/warning/maintenance', + `os` VARCHAR(100) DEFAULT NULL COMMENT '操作系统', + `cpu_cores` INT DEFAULT NULL COMMENT 'CPU核心数', + `memory_total` INT DEFAULT NULL COMMENT '内存大小(GB)', + `disk_total` INT DEFAULT NULL COMMENT '磁盘大小(GB)', + `tags` JSON DEFAULT NULL COMMENT '标签', + + `panel_url` VARCHAR(255) DEFAULT NULL COMMENT '1Panel面板地址', + `panel_port` INT DEFAULT NULL COMMENT '1Panel面板端口', + `panel_api_key` VARCHAR(255) DEFAULT NULL COMMENT '1Panel API密钥', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_ip` (`ip`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台服务器表'; + +-- ================================== +-- 平台项目表 +-- ================================== +DROP TABLE IF EXISTS `platform_project`; +CREATE TABLE `platform_project` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '项目名称', + `code` VARCHAR(50) DEFAULT NULL COMMENT '项目编码', + `type` VARCHAR(50) DEFAULT NULL COMMENT '项目类型', + `url` VARCHAR(255) DEFAULT NULL COMMENT '访问地址', + `domain_id` BIGINT DEFAULT NULL COMMENT '关联域名ID', + `server_id` BIGINT DEFAULT NULL COMMENT '关联服务器ID', + `server_name` VARCHAR(100) DEFAULT NULL COMMENT '服务器名称', + `deploy_path` VARCHAR(255) DEFAULT NULL COMMENT '部署路径', + `version` VARCHAR(50) DEFAULT NULL COMMENT '当前版本', + `status` VARCHAR(20) DEFAULT 'inactive' COMMENT '状态 active/inactive/deploying', + `icon` VARCHAR(255) DEFAULT NULL COMMENT '图标', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `sort` INT DEFAULT 0 COMMENT '排序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_code` (`code`), + KEY `idx_server_id` (`server_id`), + KEY `idx_domain_id` (`domain_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台项目表'; + +-- ================================== +-- 平台域名表 +-- ================================== +DROP TABLE IF EXISTS `platform_domain`; +CREATE TABLE `platform_domain` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `domain` VARCHAR(255) NOT NULL COMMENT '域名', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `server_id` BIGINT DEFAULT NULL COMMENT '关联服务器ID', + `server_name` VARCHAR(100) DEFAULT NULL COMMENT '关联服务器名称', + `server_ip` VARCHAR(50) DEFAULT NULL COMMENT '服务器IP', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 active/pending/expired/error', + `dns_status` VARCHAR(20) DEFAULT 'checking' COMMENT 'DNS状态 resolved/unresolved/checking', + `dns_records` JSON DEFAULT NULL COMMENT 'DNS记录', + `ssl_status` VARCHAR(20) DEFAULT 'none' COMMENT 'SSL状态 valid/expiring/expired/none', + `ssl_expire_date` DATE DEFAULT NULL COMMENT 'SSL过期时间', + `certificate_id` VARCHAR(100) DEFAULT NULL COMMENT '证书ID', + `certificate_name` VARCHAR(100) DEFAULT NULL COMMENT '证书名称', + `nginx_config_path` VARCHAR(255) DEFAULT NULL COMMENT 'Nginx配置路径', + `proxy_pass` VARCHAR(255) DEFAULT NULL COMMENT '代理地址', + `port` INT DEFAULT 80 COMMENT '端口', + `enable_https` TINYINT DEFAULT 0 COMMENT '是否启用HTTPS', + `force_https` TINYINT DEFAULT 0 COMMENT '是否强制HTTPS', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_domain` (`domain`), + KEY `idx_project_id` (`project_id`), + KEY `idx_server_id` (`server_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台域名表'; + +-- ================================== +-- 财务账户表 +-- ================================== +DROP TABLE IF EXISTS `finance_account`; +CREATE TABLE `finance_account` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '账户名称', + `type` VARCHAR(20) DEFAULT 'corporate' COMMENT '账户类型 corporate-对公账户 merchant-商户号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '开户银行', + `bank_branch` VARCHAR(100) DEFAULT NULL COMMENT '开户支行', + `account_no` VARCHAR(50) DEFAULT NULL COMMENT '银行账号', + `merchant_id` VARCHAR(100) DEFAULT NULL COMMENT '商户ID', + `merchant_platform` VARCHAR(20) DEFAULT NULL COMMENT '商户平台 wechat/alipay/unionpay/other', + `app_id` VARCHAR(100) DEFAULT NULL COMMENT 'AppID', + `balance` DECIMAL(15,2) DEFAULT 0.00 COMMENT '当前余额', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态 active-正常 inactive-禁用', + `is_default` TINYINT DEFAULT 0 COMMENT '是否默认账户', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务账户表'; + +-- ================================== +-- 财务收入表 +-- ================================== +DROP TABLE IF EXISTS `finance_income`; +CREATE TABLE `finance_income` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `income_no` VARCHAR(50) NOT NULL COMMENT '收入编号', + `type` VARCHAR(30) DEFAULT 'project' COMMENT '收入类型 project/service_fee/consulting/commission/other', + `title` VARCHAR(200) NOT NULL COMMENT '收入名称', + `customer_id` BIGINT DEFAULT NULL COMMENT '客户ID', + `customer_name` VARCHAR(100) DEFAULT NULL COMMENT '客户名称', + `customer_contact` VARCHAR(100) DEFAULT NULL COMMENT '客户联系方式', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `contract_no` VARCHAR(50) DEFAULT NULL COMMENT '合同编号', + `total_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '总金额', + `received_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '已收金额', + `pending_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '待收金额', + `account_id` BIGINT DEFAULT NULL COMMENT '收款账户ID', + `account_name` VARCHAR(100) DEFAULT NULL COMMENT '收款账户名称', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/partial/received/overdue', + `expected_date` DATE DEFAULT NULL COMMENT '预计收款日期', + `actual_date` DATE DEFAULT NULL COMMENT '实际收款日期', + `invoice_required` TINYINT DEFAULT 0 COMMENT '是否需要发票', + `invoice_issued` TINYINT DEFAULT 0 COMMENT '发票是否已开', + `invoice_no` VARCHAR(50) DEFAULT NULL COMMENT '发票号', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_income_no` (`income_no`), + KEY `idx_type` (`type`), + KEY `idx_customer_id` (`customer_id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_status` (`status`), + KEY `idx_expected_date` (`expected_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务收入表'; + +-- ================================== +-- 财务支出表 +-- ================================== +DROP TABLE IF EXISTS `finance_expense`; +CREATE TABLE `finance_expense` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `expense_no` VARCHAR(50) NOT NULL COMMENT '支出编号', + `type` VARCHAR(30) DEFAULT 'other' COMMENT '支出类型 salary/office/rent/travel/marketing/equipment/service/tax/social_insurance/other', + `title` VARCHAR(200) NOT NULL COMMENT '支出名称', + `payee_name` VARCHAR(100) DEFAULT NULL COMMENT '收款方名称', + `payee_account` VARCHAR(50) DEFAULT NULL COMMENT '收款方账号', + `payee_bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款方开户行', + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '金额', + `project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '关联项目名称', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `account_id` BIGINT DEFAULT NULL COMMENT '付款账户ID', + `account_name` VARCHAR(100) DEFAULT NULL COMMENT '付款账户名称', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` BIGINT DEFAULT NULL COMMENT '审批流程ID', + `approval_status` VARCHAR(20) DEFAULT NULL COMMENT '审批状态', + `attachments` JSON DEFAULT NULL COMMENT '附件路径', + `payment_date` DATE DEFAULT NULL COMMENT '付款日期', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_expense_no` (`expense_no`), + KEY `idx_type` (`type`), + KEY `idx_project_id` (`project_id`), + KEY `idx_department_id` (`department_id`), + KEY `idx_status` (`status`), + KEY `idx_payment_date` (`payment_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务支出表'; + +-- ================================== +-- 财务预算表 +-- ================================== +DROP TABLE IF EXISTS `finance_budget`; +CREATE TABLE `finance_budget` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '预算名称', + `period` VARCHAR(20) DEFAULT 'monthly' COMMENT '预算周期 monthly/quarterly/yearly', + `year` INT NOT NULL COMMENT '年份', + `quarter` INT DEFAULT NULL COMMENT '季度 1-4', + `month` INT DEFAULT NULL COMMENT '月份 1-12', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `project_id` BIGINT DEFAULT NULL COMMENT '项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '项目名称', + `items` JSON DEFAULT NULL COMMENT '预算明细', + `total_budget` DECIMAL(15,2) DEFAULT 0.00 COMMENT '总预算', + `used_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '已使用金额', + `remaining_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '剩余金额', + `usage_rate` DECIMAL(5,2) DEFAULT 0.00 COMMENT '使用率 0-100', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/active/completed/cancelled', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人名称', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_year` (`year`), + KEY `idx_period` (`period`), + KEY `idx_department_id` (`department_id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务预算表'; + +-- ================================== +-- 财务报销表 +-- ================================== +DROP TABLE IF EXISTS `finance_reimbursement`; +CREATE TABLE `finance_reimbursement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `reimbursement_no` VARCHAR(50) NOT NULL COMMENT '报销单号', + `type` VARCHAR(30) DEFAULT 'other' COMMENT '报销类型 travel/meal/transport/communication/office/other', + `title` VARCHAR(200) NOT NULL COMMENT '报销标题', + `applicant_id` BIGINT DEFAULT NULL COMMENT '申请人ID', + `applicant_name` VARCHAR(50) DEFAULT NULL COMMENT '申请人名称', + `department_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `department_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `total_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '总金额', + `items` JSON DEFAULT NULL COMMENT '报销明细', + `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态 draft/pending/approved/paid/rejected', + `approval_id` BIGINT DEFAULT NULL COMMENT '审批流程ID', + `approval_status` VARCHAR(20) DEFAULT NULL COMMENT '审批状态', + `current_approver` VARCHAR(50) DEFAULT NULL COMMENT '当前审批人', + `bank_account_name` VARCHAR(100) DEFAULT NULL COMMENT '收款人银行户名', + `bank_account_no` VARCHAR(50) DEFAULT NULL COMMENT '收款人银行账号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款人开户行', + `payment_account_id` BIGINT DEFAULT NULL COMMENT '付款账户ID', + `payment_date` DATE DEFAULT NULL COMMENT '付款日期', + `payment_remark` VARCHAR(500) DEFAULT NULL COMMENT '付款备注', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_reimbursement_no` (`reimbursement_no`), + KEY `idx_type` (`type`), + KEY `idx_applicant_id` (`applicant_id`), + KEY `idx_department_id` (`department_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务报销表'; + +-- ================================== +-- 财务发票表 +-- ================================== +DROP TABLE IF EXISTS `finance_invoice`; +CREATE TABLE `finance_invoice` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `settlement_id` BIGINT DEFAULT NULL COMMENT '关联结算单ID', + `type` VARCHAR(20) DEFAULT 'vat_normal' COMMENT '发票类型 vat_special/vat_normal/personal', + `title` VARCHAR(200) NOT NULL COMMENT '发票抬头', + `tax_code` VARCHAR(50) DEFAULT NULL COMMENT '税号', + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '金额', + `file_url` VARCHAR(255) DEFAULT NULL COMMENT '电子发票文件URL', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/issued/rejected', + `submit_time` DATE DEFAULT NULL COMMENT '提交时间', + `issue_time` DATE DEFAULT NULL COMMENT '开票时间', + `reject_reason` VARCHAR(500) DEFAULT NULL COMMENT '驳回原因', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_settlement_id` (`settlement_id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务发票表'; + +-- ================================== +-- 财务结算表 +-- ================================== +DROP TABLE IF EXISTS `finance_settlement`; +CREATE TABLE `finance_settlement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `project_id` BIGINT DEFAULT NULL COMMENT '项目ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '项目名称', + `talent_id` BIGINT DEFAULT NULL COMMENT '人才ID', + `talent_name` VARCHAR(50) DEFAULT NULL COMMENT '人才名称', + `period` VARCHAR(20) DEFAULT NULL COMMENT '结算周期/月份', + `total_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '项目总额', + `platform_fee` DECIMAL(15,2) DEFAULT 0.00 COMMENT '平台服务费', + `taxable_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '应纳税所得额', + `tax_rate` DECIMAL(5,4) DEFAULT 0.0000 COMMENT '税率', + `tax_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '扣税金额', + `actual_amount` DECIMAL(15,2) DEFAULT 0.00 COMMENT '实发金额', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/paying/completed/rejected', + `invoice_status` VARCHAR(20) DEFAULT 'none' COMMENT '发票状态 none/pending/received', + `bank_account_name` VARCHAR(100) DEFAULT NULL COMMENT '银行户名', + `bank_account_no` VARCHAR(50) DEFAULT NULL COMMENT '银行账号', + `bank_name` VARCHAR(100) DEFAULT NULL COMMENT '开户银行', + `audit_time` DATE DEFAULT NULL COMMENT '审核时间', + `payment_time` DATE DEFAULT NULL COMMENT '打款时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_talent_id` (`talent_id`), + KEY `idx_period` (`period`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务结算表'; + +-- ================================== +-- 初始化管理员账户 +-- 密码: admin123 (BCrypt加密) +-- ================================== +INSERT INTO `sys_user` (`username`, `password`, `nickname`, `role`, `status`, `remark`) +VALUES ('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '超级管理员', 'super_admin', 1, '系统初始管理员'); + +-- ================================== +-- 初始化示例数据 +-- ================================== + +-- 服务器示例数据 +INSERT INTO `platform_server` (`name`, `ip`, `internal_ip`, `port`, `type`, `status`, `os`, `cpu_cores`, `memory_total`, `disk_total`, `ssh_user`, `ssh_port`, `panel_port`, `panel_api_key`, `description`) +VALUES +('生产服务器', '47.109.57.58', NULL, 22, 'cloud', 'online', 'CentOS 7.9', 4, 8, 100, 'root', 22, 42588, 'KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI', '南溪小岛主生产服务器,部署1Panel面板'); + +-- 项目示例数据 +INSERT INTO `platform_project` (`name`, `code`, `type`, `status`, `version`, `description`, `sort`) +VALUES +('南溪屿后台管理', 'nanxiislet-admin', 'frontend', 'active', '1.0.0', '南溪屿后台管理系统前端', 1), +('南溪屿API服务', 'nanxiislet-api', 'backend', 'active', '1.0.0', '南溪屿后台管理系统API服务', 2); + +-- 账户示例数据 +INSERT INTO `finance_account` (`name`, `type`, `bank_name`, `bank_branch`, `account_no`, `balance`, `status`, `is_default`, `remark`) +VALUES +('公司对公账户', 'corporate', '招商银行', '上海分行', '6226****1234', 100000.00, 'active', 1, '公司主账户'); + +-- ================================== +-- 部门管理表 +-- ================================== +DROP TABLE IF EXISTS `sys_dept`; +CREATE TABLE `sys_dept` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父部门ID,0表示顶级部门', + `name` VARCHAR(100) NOT NULL COMMENT '部门名称', + `code` VARCHAR(50) DEFAULT NULL COMMENT '部门编码', + `leader_id` BIGINT DEFAULT NULL COMMENT '部门负责人ID', + `leader_name` VARCHAR(50) DEFAULT NULL COMMENT '部门负责人名称', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态 0-禁用 1-正常', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_code` (`code`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门管理表'; + +-- 初始化部门数据 +INSERT INTO `sys_dept` (`parent_id`, `name`, `code`, `sort`, `status`) VALUES +(0, '南溪屿科技', 'ROOT', 0, 1), +(1, '技术部', 'TECH', 1, 1), +(1, '产品部', 'PRODUCT', 2, 1), +(1, '财务部', 'FINANCE', 3, 1), +(1, '人力资源部', 'HR', 4, 1), +(2, '前端开发组', 'TECH-FE', 1, 1), +(2, '后端开发组', 'TECH-BE', 2, 1), +(2, '运维组', 'TECH-OPS', 3, 1); + +-- ================================== +-- 审批流程模板表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_template`; +CREATE TABLE `sys_approval_template` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(100) NOT NULL COMMENT '模板名称', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用 0-禁用 1-启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `updated_by` BIGINT DEFAULT NULL COMMENT '更新人ID', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记 0-未删除 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_scenario` (`scenario`), + KEY `idx_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程模板表'; + +-- ================================== +-- 审批流程节点表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_node`; +CREATE TABLE `sys_approval_node` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `name` VARCHAR(100) NOT NULL COMMENT '节点名称', + `approver_type` VARCHAR(20) NOT NULL DEFAULT 'specified' COMMENT '审批人类型: specified-指定人员 role-按角色 department-按部门 superior-上级领导 self_select-发起人自选', + `approver_ids` JSON DEFAULT NULL COMMENT '审批人ID列表', + `approver_role` VARCHAR(50) DEFAULT NULL COMMENT '审批角色编码', + `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID', + `dept_name` VARCHAR(100) DEFAULT NULL COMMENT '部门名称', + `approval_mode` VARCHAR(10) DEFAULT 'or' COMMENT '审批方式: and-会签 or-或签', + `timeout_hours` INT DEFAULT NULL COMMENT '超时时间(小时)', + `timeout_action` VARCHAR(20) DEFAULT NULL COMMENT '超时操作', + `sort` INT DEFAULT 0 COMMENT '节点顺序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程节点表'; + +-- ================================== +-- 审批实例表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_instance`; +CREATE TABLE `sys_approval_instance` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `template_id` BIGINT NOT NULL COMMENT '模板ID', + `template_name` VARCHAR(100) DEFAULT NULL COMMENT '模板名称', + `scenario` VARCHAR(50) NOT NULL COMMENT '适用场景', + `business_type` VARCHAR(50) NOT NULL COMMENT '业务类型', + `business_id` BIGINT NOT NULL COMMENT '业务ID', + `business_title` VARCHAR(200) DEFAULT NULL COMMENT '业务标题', + `initiator_id` BIGINT NOT NULL COMMENT '发起人ID', + `initiator_name` VARCHAR(50) DEFAULT NULL COMMENT '发起人名称', + `initiator_avatar` VARCHAR(255) DEFAULT NULL COMMENT '发起人头像', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态', + `current_node_id` BIGINT DEFAULT NULL COMMENT '当前节点ID', + `current_node_name` VARCHAR(100) DEFAULT NULL COMMENT '当前节点名称', + `submitted_at` DATETIME DEFAULT NULL COMMENT '提交时间', + `completed_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (`id`), + KEY `idx_template_id` (`template_id`), + KEY `idx_business` (`business_type`, `business_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批实例表'; + +-- ================================== +-- 审批记录表 +-- ================================== +DROP TABLE IF EXISTS `sys_approval_record`; +CREATE TABLE `sys_approval_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `instance_id` BIGINT NOT NULL COMMENT '实例ID', + `node_id` BIGINT NOT NULL COMMENT '节点ID', + `node_name` VARCHAR(100) DEFAULT NULL COMMENT '节点名称', + `approver_id` BIGINT NOT NULL COMMENT '审批人ID', + `approver_name` VARCHAR(50) DEFAULT NULL COMMENT '审批人名称', + `approver_avatar` VARCHAR(255) DEFAULT NULL COMMENT '审批人头像', + `action` VARCHAR(20) NOT NULL COMMENT '操作', + `comment` VARCHAR(500) DEFAULT NULL COMMENT '审批意见', + `operated_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_instance_id` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批记录表'; + +-- 初始化审批流程模板示例数据 +INSERT INTO `sys_approval_template` (`name`, `description`, `scenario`, `enabled`) VALUES +('费用报销审批', '员工费用报销审批流程', 'expense_reimbursement', 1), +('付款申请审批', '付款申请审批流程', 'payment_request', 1), +('项目发布审批', '项目发布前的审批流程', 'project_publish', 1);